mirror of
https://gitee.com/beecue/fastbee.git
synced 2026-11-07 17:38:56 +08:00
fix(规则脚本): 规则引擎 Groovy 脚本注入导致远程代码执行漏洞修复
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package com.fastbee.ruleEngine.config;
|
||||
|
||||
import com.fastbee.ruleEngine.security.SandboxThreadPoolExecutor;
|
||||
import com.yomahub.liteflow.thread.ExecutorBuilder;
|
||||
import org.springframework.scheduling.concurrent.CustomizableThreadFactory;
|
||||
|
||||
@@ -7,9 +8,10 @@ import java.util.concurrent.*;
|
||||
|
||||
public class MainExecutorBuilder implements ExecutorBuilder {
|
||||
private ThreadFactory springThreadFactory = new CustomizableThreadFactory("liteflow-main-");
|
||||
|
||||
@Override
|
||||
public ExecutorService buildExecutor() {
|
||||
return new ThreadPoolExecutor(
|
||||
return new SandboxThreadPoolExecutor(
|
||||
10,
|
||||
30,
|
||||
5,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.fastbee.ruleEngine.config;
|
||||
|
||||
import com.fastbee.ruleEngine.security.SandboxThreadPoolExecutor;
|
||||
import com.yomahub.liteflow.thread.ExecutorBuilder;
|
||||
import org.springframework.scheduling.concurrent.CustomizableThreadFactory;
|
||||
|
||||
@@ -7,9 +8,10 @@ import java.util.concurrent.*;
|
||||
|
||||
public class WhenExecutorBuilder implements ExecutorBuilder {
|
||||
private ThreadFactory springThreadFactory = new CustomizableThreadFactory("liteflow-when-");
|
||||
|
||||
@Override
|
||||
public ExecutorService buildExecutor() {
|
||||
return new ThreadPoolExecutor(
|
||||
return new SandboxThreadPoolExecutor(
|
||||
10,
|
||||
30,
|
||||
5,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.fastbee.ruleEngine.security;
|
||||
|
||||
import com.fastbee.ruleEngine.context.MsgContext;
|
||||
import groovy.lang.Closure;
|
||||
import groovy.lang.GString;
|
||||
import groovy.lang.Script;
|
||||
import org.kohsuke.groovy.sandbox.GroovyInterceptor;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.*;
|
||||
|
||||
/** Default-deny capabilities: never grant a package, reflection or a Spring bean. */
|
||||
final class BusinessScriptInterceptor extends GroovyInterceptor {
|
||||
private static Set<String> names(String text) { return new HashSet<>(Arrays.asList(text.split(" "))); }
|
||||
private static final Set<String> VALUES = names(
|
||||
"java.lang.String java.lang.StringBuilder java.lang.StringBuffer java.lang.Boolean java.lang.Character " +
|
||||
"java.lang.Byte java.lang.Short java.lang.Integer java.lang.Long java.lang.Float java.lang.Double " +
|
||||
"java.math.BigDecimal java.math.BigInteger java.math.RoundingMode java.util.Date java.util.UUID " +
|
||||
"java.util.ArrayList java.util.LinkedList java.util.HashMap java.util.LinkedHashMap java.util.TreeMap " +
|
||||
"java.util.HashSet java.util.LinkedHashSet java.util.TreeSet cn.hutool.json.JSONObject cn.hutool.json.JSONArray");
|
||||
private static final Set<String> DATA_METHODS = names(
|
||||
"toString equals hashCode compareTo isCase asBoolean size length isEmpty contains containsKey containsValue " +
|
||||
"get getAt getOrDefault put putAt putAll putIfAbsent add addAll set remove removeAll retainAll clear " +
|
||||
"keySet values entrySet getKey getValue setValue iterator listIterator hasNext next hasPrevious previous " +
|
||||
"each eachWithIndex forEach collect collectMany collectEntries find findAll findIndexOf findIndexValues findResult " +
|
||||
"any every count countBy groupBy inject sum min max sort reverse unique flatten join take drop takeWhile dropWhile " +
|
||||
"first last head tail init toList toSet toArray subList collate transpose intersect disjoint plus minus multiply " +
|
||||
"div intdiv mod power abs negate positive negative next previous round setScale scale precision signum " +
|
||||
"add subtract divide remainder pow movePointLeft movePointRight stripTrailingZeros toPlainString " +
|
||||
"intValue longValue doubleValue floatValue shortValue byteValue booleanValue toInteger toLong toDouble " +
|
||||
"toFloat toBigDecimal toBigInteger toBoolean isNumber isInteger isLong isDouble isBigDecimal " +
|
||||
"trim strip toLowerCase toUpperCase startsWith endsWith indexOf lastIndexOf substring subSequence charAt " +
|
||||
"replace replaceAll replaceFirst split tokenize matches findAll padLeft padRight capitalize " +
|
||||
"uncapitalize center getBytes getChars toCharArray concat equalsIgnoreCase compareToIgnoreCase " +
|
||||
"append insert delete deleteCharAt encodeBase64 decodeBase64 encodeHex decodeHex " +
|
||||
"getTime setTime before after format getYear getMonth getDate getHours getMinutes getSeconds " +
|
||||
"getStr getInt getLong getDouble getFloat getBool getBigDecimal getBigInteger getJSONObject getJSONArray " +
|
||||
"getObj getByPath setOnce toJSONString toStringPretty toJsonStr toJsonPrettyStr upto downto times step withIndex");
|
||||
private static final Set<String> CONTEXT_METHODS = names(
|
||||
"getTopic setTopic getPayload setPayload getSerialNumber setSerialNumber getProductId setProductId " +
|
||||
"getProtocolCode setProtocolCode getData setData hasData printlog placeholders getDataMap setDataMap");
|
||||
private static final Set<String> CONTEXT_PROPERTIES = names("topic payload serialNumber productId protocolCode dataMap");
|
||||
private static final Map<String, Set<String>> STATICS = new HashMap<>();
|
||||
static {
|
||||
STATICS.put("cn.hutool.json.JSONUtil", names("parse parseObj parseArray toJsonStr toJsonPrettyStr isTypeJSON isTypeJSONObject isTypeJSONArray quote escape wrap"));
|
||||
STATICS.put("cn.hutool.core.util.NumberUtil", names("add sub mul div round roundStr isNumber isInteger isLong isDouble isBigDecimal compare equals parseInt parseLong parseDouble parseFloat toBigDecimal toBigInteger max min count range"));
|
||||
STATICS.put("java.lang.Math", names("abs min max round ceil floor sqrt pow log log10 exp sin cos tan asin acos atan atan2 random signum toDegrees toRadians"));
|
||||
STATICS.put("java.lang.System", names("currentTimeMillis nanoTime"));
|
||||
STATICS.put("java.util.UUID", names("randomUUID fromString"));
|
||||
STATICS.put("java.util.Arrays", names("asList toString deepToString copyOf copyOfRange equals deepEquals sort binarySearch"));
|
||||
STATICS.put("java.util.Collections", names("emptyList emptyMap emptySet singleton singletonList singletonMap sort reverse min max frequency disjoint nCopies"));
|
||||
STATICS.put("cn.hutool.http.HttpUtil", names("get post createGet createPost createRequest"));
|
||||
STATICS.put("cn.hutool.http.HttpRequest", names("get post put delete patch head options"));
|
||||
}
|
||||
private final Class<? extends Script> scriptType;
|
||||
private final Map<String, Object> variables;
|
||||
private final Set<String> helpers = new HashSet<>();
|
||||
BusinessScriptInterceptor(Class<? extends Script> scriptType, Map<String, Object> variables) {
|
||||
this.scriptType = scriptType;
|
||||
this.variables = variables;
|
||||
for (Method m : scriptType.getDeclaredMethods()) {
|
||||
if (!m.isSynthetic() && !names("run main getMetaClass setMetaClass invokeMethod getProperty setProperty").contains(m.getName())) helpers.add(m.getName());
|
||||
}
|
||||
}
|
||||
private SecurityException denied(Object receiver, String operation) {
|
||||
String type = receiver == null ? "null" : receiver instanceof Class ? ((Class<?>) receiver).getName() : receiver.getClass().getName();
|
||||
return new SecurityException("规则脚本无此能力: " + type + "." + operation);
|
||||
}
|
||||
private boolean data(Object object) {
|
||||
if (object == null) return false;
|
||||
Class<?> c = object.getClass();
|
||||
return VALUES.contains(c.getName()) || object instanceof GString || c.isArray()
|
||||
|| (c.getClassLoader() == null && c.getName().startsWith("java.util.")
|
||||
&& (object instanceof Collection || object instanceof Map || object instanceof Map.Entry || object instanceof Iterator))
|
||||
|| c.getName().equals("groovy.lang.IntRange") || c.getName().equals("groovy.lang.EmptyRange");
|
||||
}
|
||||
private boolean staticAllowed(Class<?> type, String method) {
|
||||
Set<String> methods = STATICS.get(type.getName());
|
||||
if (type.getName().startsWith("cn.hutool.http.") && !ScriptSandbox.isNetworkAllowed()) return false;
|
||||
return methods != null && methods.contains(method)
|
||||
|| VALUES.contains(type.getName()) && names("valueOf parseInt parseLong parseDouble parseFloat parseBoolean parseByte parseShort toString format join").contains(method);
|
||||
}
|
||||
@Override public Object onMethodCall(Invoker invoker, Object receiver, String method, Object... args) throws Throwable {
|
||||
checkArguments(args);
|
||||
if (receiver instanceof Class) {
|
||||
if (!staticAllowed((Class<?>) receiver, method)) throw denied(receiver, method);
|
||||
} else if (receiver != null && receiver.getClass() == scriptType) {
|
||||
if (!helpers.contains(method)) throw denied(receiver, method);
|
||||
} else if (receiver instanceof Closure && receiver.getClass().getClassLoader() == scriptType.getClassLoader()) {
|
||||
if (!names("call doCall curry rcurry ncurry memoize trampoline").contains(method)) throw denied(receiver, method);
|
||||
} else if (receiver != null && receiver.getClass() == MsgContext.class) {
|
||||
if ("getLogger".equals(method) && args.length == 0) return ScriptLog.INSTANCE;
|
||||
if (!CONTEXT_METHODS.contains(method)) throw denied(receiver, method);
|
||||
} else if (receiver == ScriptLog.INSTANCE) {
|
||||
if (!names("debug info warn error trace isDebugEnabled isInfoEnabled isWarnEnabled isErrorEnabled isTraceEnabled").contains(method)) throw denied(receiver, method);
|
||||
} else if (receiver == ScriptConsole.OUT || receiver == ScriptConsole.ERR) {
|
||||
if (!names("print println printf format").contains(method)) throw denied(receiver, method);
|
||||
} else if (receiver != null && receiver == variables.get("sceneContext")
|
||||
&& receiver.getClass().getName().equals("com.fastbee.mq.ruleEngine.SceneContext")) {
|
||||
if (!"process".equals(method) || args.length != 1 || !(args[0] instanceof String)) throw denied(receiver, method);
|
||||
} else if (receiver != null && receiver.getClass().getName().equals("cn.hutool.http.HttpRequest")) {
|
||||
if (!ScriptSandbox.isNetworkAllowed() || !names("header body form timeout setConnectionTimeout setReadTimeout execute").contains(method)) throw denied(receiver, method);
|
||||
} else if (receiver != null && receiver.getClass().getName().equals("cn.hutool.http.HttpResponse")) {
|
||||
if (!names("body bodyBytes getStatus isOk header headers close").contains(method)) throw denied(receiver, method);
|
||||
} else {
|
||||
if (!data(receiver) || !DATA_METHODS.contains(method)) throw denied(receiver, method);
|
||||
if (("getAt".equals(method) || "putAt".equals(method)) && args.length > 0) checkIndex(receiver, args[0]);
|
||||
}
|
||||
return super.onMethodCall(invoker, receiver, method, args);
|
||||
}
|
||||
@Override public Object onStaticCall(Invoker invoker, Class receiver, String method, Object... args) throws Throwable {
|
||||
checkArguments(args);
|
||||
if (!staticAllowed(receiver, method)) throw denied(receiver, method);
|
||||
return super.onStaticCall(invoker, receiver, method, args);
|
||||
}
|
||||
@Override public Object onNewInstance(Invoker invoker, Class receiver, Object... args) throws Throwable {
|
||||
checkArguments(args);
|
||||
if (!VALUES.contains(receiver.getName())) throw denied(receiver, "new");
|
||||
return super.onNewInstance(invoker, receiver, args);
|
||||
}
|
||||
@Override public Object onSuperCall(Invoker invoker, Class sender, Object receiver, String method, Object... args) { throw denied(receiver, "super." + method); }
|
||||
@Override public void onSuperConstructor(Invoker invoker, Class receiver, Object... args) { throw denied(receiver, "super"); }
|
||||
@Override public Object onGetProperty(Invoker invoker, Object receiver, String property) throws Throwable {
|
||||
// Keep existing rule scripts working without handing them the shared JVM streams.
|
||||
if (receiver == System.class && "out".equals(property)) return ScriptConsole.OUT;
|
||||
if (receiver == System.class && "err".equals(property)) return ScriptConsole.ERR;
|
||||
if (receiver != null && receiver.getClass() == MsgContext.class && "logger".equals(property)) return ScriptLog.INSTANCE;
|
||||
if (receiver != null && receiver.getClass() == scriptType && variables.containsKey(property)) return variables.get(property);
|
||||
if (receiver instanceof Map) return ((Map<?, ?>) receiver).get(property);
|
||||
if (receiver != null && receiver.getClass() == MsgContext.class && CONTEXT_PROPERTIES.contains(property)) return super.onGetProperty(invoker, receiver, property);
|
||||
if (receiver instanceof Class && VALUES.contains(((Class<?>) receiver).getName())) {
|
||||
java.lang.reflect.Field field = ((Class<?>) receiver).getField(property);
|
||||
if (java.lang.reflect.Modifier.isStatic(field.getModifiers()) && java.lang.reflect.Modifier.isFinal(field.getModifiers())
|
||||
&& (field.getType().isPrimitive() || field.getType() == String.class || field.getType().isEnum())) return field.get(null);
|
||||
}
|
||||
if (data(receiver) && names("size length bytes empty time").contains(property)) return super.onGetProperty(invoker, receiver, property);
|
||||
throw denied(receiver, property);
|
||||
}
|
||||
@Override public Object onSetProperty(Invoker invoker, Object receiver, String property, Object value) throws Throwable {
|
||||
if (receiver instanceof Map) { ((Map) receiver).put(property, value); return value; }
|
||||
if (receiver != null && receiver.getClass() == MsgContext.class && CONTEXT_PROPERTIES.contains(property)) return super.onSetProperty(invoker, receiver, property, value);
|
||||
throw denied(receiver, property);
|
||||
}
|
||||
@Override public Object onGetAttribute(Invoker invoker, Object receiver, String attribute) { throw denied(receiver, attribute); }
|
||||
@Override public Object onSetAttribute(Invoker invoker, Object receiver, String attribute, Object value) { throw denied(receiver, attribute); }
|
||||
private void checkIndex(Object receiver, Object index) {
|
||||
if (receiver instanceof Map) return;
|
||||
if (!data(receiver) || !(index instanceof Number || index instanceof groovy.lang.Range || index instanceof Collection)) throw denied(receiver, "index");
|
||||
if (index instanceof Collection) for (Object i : (Collection<?>) index) if (!(i instanceof Number)) throw denied(receiver, "index");
|
||||
}
|
||||
private void checkArguments(Object[] args) {
|
||||
Set<Object> visited = Collections.newSetFromMap(new IdentityHashMap<Object, Boolean>());
|
||||
for (Object arg : args) checkArgument(arg, visited);
|
||||
}
|
||||
private void checkArgument(Object arg, Set<Object> visited) {
|
||||
if (arg == null || !visited.add(arg)) return;
|
||||
if (arg instanceof Class) {
|
||||
Class<?> type = (Class<?>) arg;
|
||||
if (!VALUES.contains(type.getName()) && !type.isPrimitive()) throw denied(type, "argument type");
|
||||
} else if (arg instanceof Map) {
|
||||
for (Object entry : ((Map<?, ?>) arg).entrySet()) {
|
||||
checkArgument(((Map.Entry<?, ?>) entry).getKey(), visited);
|
||||
checkArgument(((Map.Entry<?, ?>) entry).getValue(), visited);
|
||||
}
|
||||
} else if (arg instanceof Collection) {
|
||||
for (Object value : (Collection<?>) arg) checkArgument(value, visited);
|
||||
} else if (arg instanceof Object[]) {
|
||||
for (Object value : (Object[]) arg) checkArgument(value, visited);
|
||||
} else if (!data(arg) && arg.getClass() != MsgContext.class
|
||||
&& !(arg instanceof Closure && arg.getClass().getClassLoader() == scriptType.getClassLoader())) {
|
||||
throw denied(arg, "argument");
|
||||
}
|
||||
}
|
||||
@Override public Object onGetArray(Invoker invoker, Object receiver, Object index) throws Throwable { checkIndex(receiver, index); return super.onGetArray(invoker, receiver, index); }
|
||||
@Override public Object onSetArray(Invoker invoker, Object receiver, Object index, Object value) throws Throwable { checkIndex(receiver, index); return super.onSetArray(invoker, receiver, index, value); }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.fastbee.ruleEngine.security;
|
||||
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** Compatibility executor; each script invocation installs its own mandatory interceptor. */
|
||||
public class SandboxThreadPoolExecutor extends ThreadPoolExecutor {
|
||||
|
||||
public SandboxThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime,
|
||||
TimeUnit unit, BlockingQueue<Runnable> workQueue,
|
||||
ThreadFactory threadFactory) {
|
||||
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Runnable command) {
|
||||
super.execute(ScriptSandbox.propagate(command));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.fastbee.ruleEngine.security;
|
||||
|
||||
/** Legacy console syntax backed by the script logger, never a JVM PrintStream. */
|
||||
public final class ScriptConsole {
|
||||
static final ScriptConsole OUT = new ScriptConsole(false);
|
||||
static final ScriptConsole ERR = new ScriptConsole(true);
|
||||
private final boolean error;
|
||||
|
||||
private ScriptConsole(boolean error) { this.error = error; }
|
||||
|
||||
private void write(String message) {
|
||||
if (error) ScriptLog.INSTANCE.error(message);
|
||||
else ScriptLog.INSTANCE.info(message);
|
||||
}
|
||||
|
||||
public void print(Object value) { write(String.valueOf(value)); }
|
||||
public void println(Object value) { print(value); }
|
||||
public void println() { write(""); }
|
||||
public ScriptConsole printf(String format, Object... args) {
|
||||
write(String.format(format, args));
|
||||
return this;
|
||||
}
|
||||
public ScriptConsole format(String format, Object... args) { return printf(format, args); }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.fastbee.ruleEngine.security;
|
||||
|
||||
/** Preserve msgContext.logger without exposing Logback configuration or its application context. */
|
||||
public final class ScriptLog {
|
||||
static final ScriptLog INSTANCE = new ScriptLog();
|
||||
private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger("script");
|
||||
private ScriptLog() {}
|
||||
public void debug(String message, Object... args) { LOG.debug(message, args); }
|
||||
public void info(String message, Object... args) { LOG.info(message, args); }
|
||||
public void warn(String message, Object... args) { LOG.warn(message, args); }
|
||||
public void error(String message, Object... args) { LOG.error(message, args); }
|
||||
public void trace(String message, Object... args) { LOG.trace(message, args); }
|
||||
public boolean isDebugEnabled() { return LOG.isDebugEnabled(); }
|
||||
public boolean isInfoEnabled() { return LOG.isInfoEnabled(); }
|
||||
public boolean isWarnEnabled() { return LOG.isWarnEnabled(); }
|
||||
public boolean isErrorEnabled() { return LOG.isErrorEnabled(); }
|
||||
public boolean isTraceEnabled() { return LOG.isTraceEnabled(); }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.fastbee.ruleEngine.security;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import javax.annotation.PostConstruct;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/** Compatibility facade. The mandatory compiler and per-invocation interceptor enforce security. */
|
||||
@Component
|
||||
public final class ScriptSandbox {
|
||||
private static volatile boolean networkAllowed = true;
|
||||
@Value("${script.sandbox.enabled:true}") private boolean enabled = true;
|
||||
@Value("${script.sandbox.allow-network:true}") private boolean allowNetwork = true;
|
||||
@PostConstruct public void init() {
|
||||
if (!enabled) throw new IllegalStateException("不允许关闭规则脚本安全校验");
|
||||
networkAllowed = allowNetwork;
|
||||
}
|
||||
static boolean isNetworkAllowed() { return networkAllowed; }
|
||||
public static boolean isEnforced() { return true; }
|
||||
public static Runnable propagate(Runnable command) { return command; }
|
||||
public <T> T call(Supplier<T> action) { return action.get(); }
|
||||
public void run(Runnable action) { action.run(); }
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.fastbee.ruleEngine.security;
|
||||
|
||||
import com.yomahub.liteflow.enums.ScriptTypeEnum;
|
||||
import com.yomahub.liteflow.script.ScriptExecuteWrap;
|
||||
import com.yomahub.liteflow.script.ScriptExecutor;
|
||||
import groovy.lang.Binding;
|
||||
import groovy.lang.GroovyClassLoader;
|
||||
import groovy.lang.Script;
|
||||
import org.codehaus.groovy.ast.*;
|
||||
import org.codehaus.groovy.classgen.GeneratorContext;
|
||||
import org.codehaus.groovy.control.*;
|
||||
import org.codehaus.groovy.control.customizers.CompilationCustomizer;
|
||||
import org.codehaus.groovy.control.customizers.ImportCustomizer;
|
||||
import org.codehaus.groovy.runtime.InvokerHelper;
|
||||
import org.kohsuke.groovy.sandbox.SandboxTransformer;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/** The only LiteFlow script SPI: validation, SQL startup loading and hot reload share this compiler. */
|
||||
public final class SecureGroovyScriptExecutor extends ScriptExecutor {
|
||||
private static final SecureGroovyScriptExecutor SHARED = new SecureGroovyScriptExecutor();
|
||||
private final Map<String, Class<? extends Script>> scripts = new ConcurrentHashMap<>();
|
||||
// LiteFlow keeps separate language/default-language SPI keys; they must share reload/unload state.
|
||||
@Override public ScriptExecutor init() { return SHARED; }
|
||||
|
||||
@Override public ScriptTypeEnum scriptType() { return ScriptTypeEnum.GROOVY; }
|
||||
@Override public void load(String nodeId, String source) { scripts.put(nodeId, compileSource(source)); }
|
||||
public void install(String nodeId, Class<? extends Script> type) { scripts.put(nodeId, type); }
|
||||
@Override public void unLoad(String nodeId) { scripts.remove(nodeId); }
|
||||
@Override public List<String> getNodeIds() { return new ArrayList<>(scripts.keySet()); }
|
||||
@Override public void cleanCache() { scripts.clear(); }
|
||||
@Override public Object compile(String source) { return compileSource(source); }
|
||||
|
||||
@Override public Object executeScript(ScriptExecuteWrap wrap) {
|
||||
Class<? extends Script> type = scripts.get(wrap.getNodeId());
|
||||
if (type == null) throw new IllegalStateException("Script is disabled or not loaded: " + wrap.getNodeId());
|
||||
Map<String, Object> variables = new HashMap<>();
|
||||
bindParam(wrap, variables::put, variables::putIfAbsent);
|
||||
return evaluate(type, variables);
|
||||
}
|
||||
|
||||
public static Object evaluate(Class<? extends Script> type, Map<String, Object> variables) {
|
||||
BusinessScriptInterceptor policy = new BusinessScriptInterceptor(type, variables);
|
||||
policy.register();
|
||||
try {
|
||||
// Register BEFORE construction: field initializers are untrusted code too.
|
||||
return InvokerHelper.createScript(type, new Binding(new HashMap<>(variables))).run();
|
||||
} finally {
|
||||
policy.unregister();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static Class<? extends Script> compileSource(String source) {
|
||||
if (source == null || source.trim().isEmpty()) throw new IllegalArgumentException("脚本内容不能为空");
|
||||
if (source.length() > 262144) throw new IllegalArgumentException("脚本不能超过 256 KiB");
|
||||
CompilerConfiguration config = new CompilerConfiguration();
|
||||
// No dependency downloads or classpath-provided global compilation callbacks.
|
||||
Set<String> disabled = new HashSet<>();
|
||||
disabled.add("groovy.grape.GrabAnnotationTransformation");
|
||||
try {
|
||||
Enumeration<URL> resources = SecureGroovyScriptExecutor.class.getClassLoader().getResources(
|
||||
"META-INF/services/org.codehaus.groovy.transform.ASTTransformation");
|
||||
while (resources.hasMoreElements()) {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(
|
||||
resources.nextElement().openStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
line = line.split("#", 2)[0].trim();
|
||||
if (!line.isEmpty()) disabled.add(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (java.io.IOException e) { throw new IllegalStateException("Cannot establish compiler policy", e); }
|
||||
config.setDisabledGlobalASTTransformations(disabled);
|
||||
ImportCustomizer imports = new ImportCustomizer();
|
||||
imports.addImports("cn.hutool.json.JSONArray", "cn.hutool.json.JSONObject",
|
||||
"cn.hutool.json.JSONUtil", "cn.hutool.core.util.NumberUtil");
|
||||
config.addCompilationCustomizers(new SourceBoundary(), imports, new SandboxTransformer() {
|
||||
@Override public void processConstructors(ClassCodeExpressionTransformer visitor, ClassNode node) {
|
||||
// Groovy 3 synthesizes Script(Binding) before this phase. Transforming that
|
||||
// trusted constructor with the Groovy 2 adapter generates invalid bytecode.
|
||||
// Only compiler-generated constructors of the fixed Script base can skip it.
|
||||
if (!node.isScript() || !"groovy.lang.Script".equals(node.getSuperClass().getName()))
|
||||
throw new SecurityException("Unsupported script base class");
|
||||
for (ConstructorNode constructor : node.getDeclaredConstructors())
|
||||
if (constructor.getLineNumber() >= 0) throw new SecurityException("Custom constructors are forbidden");
|
||||
}
|
||||
});
|
||||
try (GroovyClassLoader loader = new GroovyClassLoader(
|
||||
SecureGroovyScriptExecutor.class.getClassLoader(), config)) {
|
||||
return (Class<? extends Script>) loader.parseClass(source);
|
||||
} catch (java.io.IOException e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
|
||||
/** Runs before semantic analysis, hence before local AST transforms such as ASTTest. */
|
||||
private static final class SourceBoundary extends CompilationCustomizer {
|
||||
SourceBoundary() { super(CompilePhase.CONVERSION); }
|
||||
@Override public void call(SourceUnit source, GeneratorContext context, ClassNode node) {
|
||||
if (!node.isScript() || source.getAST().getPackage() != null) {
|
||||
throw new SecurityException("规则脚本不允许声明类或包");
|
||||
}
|
||||
ClassCodeVisitorSupport visitor = new ClassCodeVisitorSupport() {
|
||||
@Override protected SourceUnit getSourceUnit() { return source; }
|
||||
@Override public void visitAnnotations(AnnotatedNode annotated) {
|
||||
if (!annotated.getAnnotations().isEmpty()) {
|
||||
throw new SecurityException("规则脚本不允许注解或编译期转换");
|
||||
}
|
||||
}
|
||||
};
|
||||
for (MethodNode method : node.getMethods()) {
|
||||
if (method.getLineNumber() > 0 && Arrays.asList("getMetaClass", "setMetaClass", "getProperty",
|
||||
"setProperty", "invokeMethod", "getBinding", "setBinding", "finalize").contains(method.getName())) {
|
||||
throw new SecurityException("不能覆盖脚本运行时基础方法");
|
||||
}
|
||||
}
|
||||
visitor.visitClass(node);
|
||||
for (ImportNode n : source.getAST().getImports()) visitor.visitAnnotations(n);
|
||||
for (ImportNode n : source.getAST().getStarImports()) visitor.visitAnnotations(n);
|
||||
for (ImportNode n : source.getAST().getStaticImports().values()) visitor.visitAnnotations(n);
|
||||
for (ImportNode n : source.getAST().getStaticStarImports().values()) visitor.visitAnnotations(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.fastbee.ruleEngine.security.SecureGroovyScriptExecutor
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.fastbee.ruleEngine.security;
|
||||
|
||||
import com.fastbee.ruleEngine.context.MsgContext;
|
||||
import com.yomahub.liteflow.script.ScriptExecutor;
|
||||
import org.junit.Test;
|
||||
import java.util.*;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
public class SecureGroovyScriptExecutorTest {
|
||||
private Object run(String source) { return run(source, Collections.emptyMap()); }
|
||||
private Object run(String source, Map<String, Object> values) {
|
||||
return SecureGroovyScriptExecutor.evaluate(SecureGroovyScriptExecutor.compileSource(source), values);
|
||||
}
|
||||
private void rejected(String source) {
|
||||
try { run(source); fail("Unsafe script accepted: " + source); }
|
||||
catch (Exception expected) { /* Compile rejection or capability denial, never script side effects. */ }
|
||||
}
|
||||
@Test public void helperMethodsClosuresLoopsAndKeywordsInText() {
|
||||
assertEquals(12, run("def twice(x) { x * 2 }; def total = 0; [1,2,3].each { total += twice(it) }; assert 'ProcessBuilder' == 'ProcessBuilder'; return total"));
|
||||
}
|
||||
@Test public void jsonAndMutableMessageContext() {
|
||||
MsgContext message = MsgContext.builder().topic("/1/device/property/post").payload("{\"value\":2}").build();
|
||||
Object result = run("def obj = JSONUtil.parseObj(msgContext.getPayload()); obj.set('value', NumberUtil.mul(obj.getInt('value'), 3)); msgContext.setData('v', obj.getInt('value')); msgContext.setTopic('/1/device/property/post'); msgContext.setPayload(obj.toString()); return msgContext.getData('v')", Collections.singletonMap("msgContext", message));
|
||||
assertEquals(6, ((Number) result).intValue());
|
||||
assertTrue(message.getPayload().contains("6"));
|
||||
}
|
||||
@Test public void databaseSeedMessageForwardingConvertsObjectPayload() throws Exception {
|
||||
String source;
|
||||
try (java.io.InputStream input = getClass().getResourceAsStream("/database-message-forwarding.groovy")) {
|
||||
assertNotNull(input);
|
||||
source = new String(readBytes(input), java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
MsgContext message = MsgContext.builder().topic("/41/D1ELV3A5TOJS/property/post")
|
||||
.productId(41L).serialNumber("D1ELV3A5TOJS").protocolCode("JSON").payload("{\"co2\":666}").build();
|
||||
run(source, Collections.singletonMap("msgContext", message));
|
||||
assertEquals("/41/D1ELV3A5TOJS/property/post", message.getTopic());
|
||||
cn.hutool.json.JSONArray values = cn.hutool.json.JSONUtil.parseArray(message.getPayload());
|
||||
assertEquals(1, values.size());
|
||||
assertEquals("co2", values.getJSONObject(0).getStr("id"));
|
||||
assertEquals("666", values.getJSONObject(0).getStr("value"));
|
||||
}
|
||||
@Test public void safeConsoleLoggingDoesNotExposeJvmStreams() {
|
||||
java.io.PrintStream originalOut = System.out, originalErr = System.err;
|
||||
assertEquals(7, run("System.out.println('legacy log'); System.err.println('legacy warning'); "
|
||||
+ "System.out.print('value='); System.out.printf('%s=%d', 'co2', 666); return 7"));
|
||||
rejected("System.out.close()");
|
||||
rejected("System.err.close()");
|
||||
rejected("System.out.getClass()");
|
||||
rejected("System.out.class");
|
||||
rejected("System.setOut(System.out)");
|
||||
rejected("System.setErr(System.err)");
|
||||
rejected("System.in.read()");
|
||||
assertSame(originalOut, System.out); assertSame(originalErr, System.err);
|
||||
}
|
||||
@Test public void javaCollectionForEachCallbacksRemainSandboxed() {
|
||||
assertEquals(Arrays.asList(2, 4), run("def result=[]; [1,2].forEach(value -> { result.add(value * 2) }); return result"));
|
||||
System.clearProperty("fastbee.security.callback");
|
||||
rejected("[1].forEach(value -> { System.setProperty('fastbee.security.callback','bad') })");
|
||||
assertNull(System.getProperty("fastbee.security.callback"));
|
||||
}
|
||||
@Test public void annotationsCannotRunDuringCompilation() {
|
||||
System.clearProperty("fastbee.security.ast");
|
||||
rejected("@groovy.transform.ASTTest(value={ System.setProperty('fastbee.security.ast','executed') }) def x = 1");
|
||||
assertNull(System.getProperty("fastbee.security.ast"));
|
||||
rejected("@groovy.lang.Grab('invalid:artifact:0') def x = 1");
|
||||
rejected("@groovy.transform.CompileStatic def x() { return 1 }; x()");
|
||||
}
|
||||
@Test public void commandApisAndSandboxSwitchAreDenied() {
|
||||
rejected("['cmd.exe','/d','/c','echo FASTBEE_SECURITY_TEST'].execute()");
|
||||
rejected("Runtime.getRuntime().exec('echo FASTBEE_SECURITY_TEST')");
|
||||
rejected("new ProcessBuilder('echo','FASTBEE_SECURITY_TEST').start()");
|
||||
rejected("com.fastbee.ruleEngine.security.ScriptSandbox.exit()");
|
||||
rejected("org.kohsuke.groovy.sandbox.GroovyInterceptor.getApplicableInterceptors().clear()");
|
||||
}
|
||||
@Test public void reflectionFilesThreadsAndNestedEnginesAreDenied() {
|
||||
rejected("Class.forName('java.lang.Runtime')");
|
||||
rejected("''.getClass().forName('java.lang.Runtime')");
|
||||
rejected("''.metaClass");
|
||||
rejected("this.binding");
|
||||
rejected("''.getAt('class')");
|
||||
rejected("new File('fastbee-security-no-such-file').text");
|
||||
rejected("new Thread({}).start()");
|
||||
rejected("new GroovyShell().evaluate('1')");
|
||||
rejected("def f = Class.&forName; f('java.lang.Runtime')");
|
||||
}
|
||||
@Test public void nestedPropertyAndArrayDataStillWork() {
|
||||
assertEquals("OK", run("def x=[a:[1,2,3]]; assert x.a[1] == 2; x.a[1]=4; assert x.a.sum()==8; return 'ok'.toUpperCase()"));
|
||||
}
|
||||
@Test public void spiHasNoUnrestrictedFallback() {
|
||||
List<ScriptExecutor> executors = new ArrayList<>();
|
||||
ServiceLoader.load(ScriptExecutor.class).forEach(executors::add);
|
||||
assertEquals(1, executors.size());
|
||||
assertEquals(SecureGroovyScriptExecutor.class, executors.get(0).getClass());
|
||||
}
|
||||
@Test public void compilationDoesNotPublishAndDisabledNodesUnload() {
|
||||
SecureGroovyScriptExecutor executor = new SecureGroovyScriptExecutor();
|
||||
executor.compile("return 1");
|
||||
assertTrue(executor.getNodeIds().isEmpty());
|
||||
executor.load("Dtest", "return 2");
|
||||
assertEquals(Collections.singletonList("Dtest"), executor.getNodeIds());
|
||||
executor.unLoad("Dtest");
|
||||
assertTrue(executor.getNodeIds().isEmpty());
|
||||
}
|
||||
@Test public void unregisteredExecutionFailsClosed() {
|
||||
try {
|
||||
SecureGroovyScriptExecutor.compileSource("return [1,2].sum()").newInstance().run();
|
||||
fail("Missing interceptor accepted");
|
||||
} catch (Exception expected) { }
|
||||
}
|
||||
@Test public void httpIntegrationRetainsExecuteMethod() throws Exception {
|
||||
com.sun.net.httpserver.HttpServer server = com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/test", exchange -> { byte[] body = "ok".getBytes(java.nio.charset.StandardCharsets.UTF_8); exchange.sendResponseHeaders(200, body.length); exchange.getResponseBody().write(body); exchange.close(); });
|
||||
server.start();
|
||||
try {
|
||||
assertEquals("ok", run("return cn.hutool.http.HttpRequest.get('http://127.0.0.1:" + server.getAddress().getPort() + "/test').timeout(2000).execute().body()"));
|
||||
} finally { server.stop(0); }
|
||||
}
|
||||
@Test public void originalFrontendTemplateAndLoggerAreCompatible() throws Exception {
|
||||
String source;
|
||||
try (java.io.InputStream input = getClass().getResourceAsStream("/default-data-flow.groovy")) {
|
||||
assertNotNull(input);
|
||||
source = new String(readBytes(input), java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
MsgContext message = MsgContext.builder().topic("/41/device/property/post").payload("{\"temperature\":23}").build();
|
||||
run(source, Collections.singletonMap("msgContext", message));
|
||||
assertEquals("/41/device/property/post", message.getTopic());
|
||||
assertEquals("{\"temperature\":23}", message.getPayload());
|
||||
try { run("msgContext.logger.getLoggerContext()", Collections.singletonMap("msgContext", message)); fail(); }
|
||||
catch (SecurityException expected) { }
|
||||
}
|
||||
private byte[] readBytes(java.io.InputStream input) throws Exception {
|
||||
java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[4096]; int size;
|
||||
while ((size = input.read(buffer)) != -1) output.write(buffer, 0, size);
|
||||
return output.toByteArray();
|
||||
}
|
||||
@Test public void unsafeConversionAndMetaprogrammingAreDenied() {
|
||||
rejected("def j=JSONUtil.parseObj('{}'); j.get('x', ProcessBuilder)");
|
||||
rejected("def getMetaClass() { return null }; 1");
|
||||
rejected("System.setSecurityManager(null)");
|
||||
rejected("def x = 'java.lang.Runtime' as Class; x.getRuntime()");
|
||||
rejected("def a = ['cmd.exe']; a.('exe'+'cute')()");
|
||||
rejected("class Unsafe { static x=System.setProperty('fastbee.security.ast','bad') }; 1");
|
||||
}
|
||||
@Test public void actualLiteflowSequentialParallelAndAsyncPathsAreProtected() throws Exception {
|
||||
com.yomahub.liteflow.property.LiteflowConfig config=new com.yomahub.liteflow.property.LiteflowConfig();
|
||||
com.yomahub.liteflow.core.FlowExecutor executor=new com.yomahub.liteflow.core.FlowExecutor(config);
|
||||
com.yomahub.liteflow.builder.LiteFlowNodeBuilder.createScriptNode().setId("DsafeA").setLanguage("groovy").setScript("msgContext.setData('a',1)").build();
|
||||
com.yomahub.liteflow.builder.LiteFlowNodeBuilder.createScriptNode().setId("DsafeB").setLanguage("groovy").setScript("msgContext.setData('b',2)").build();
|
||||
com.yomahub.liteflow.builder.el.LiteFlowChainELBuilder.createChain().setChainName("securityParallel").setEL("WHEN(DsafeA,DsafeB)").build();
|
||||
MsgContext context=new MsgContext();
|
||||
com.yomahub.liteflow.flow.LiteflowResponse response=executor.execute2Future("securityParallel",null,context).get(10,java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertTrue(response.getMessage(),response.isSuccess()); assertEquals(Integer.valueOf(1),context.getData("a")); assertEquals(Integer.valueOf(2),context.getData("b"));
|
||||
com.yomahub.liteflow.builder.LiteFlowNodeBuilder.createScriptNode().setId("Dunsafe").setLanguage("groovy").setScript("System.setProperty('fastbee.security.liteflow','bad')").build();
|
||||
com.yomahub.liteflow.builder.el.LiteFlowChainELBuilder.createChain().setChainName("securityUnsafe").setEL("THEN(Dunsafe)").build();
|
||||
System.clearProperty("fastbee.security.liteflow");
|
||||
assertFalse(executor.execute2Resp("securityUnsafe",null,new MsgContext()).isSuccess());
|
||||
assertNull(System.getProperty("fastbee.security.liteflow"));
|
||||
com.yomahub.liteflow.builder.el.LiteFlowChainELBuilder.createChain().setChainName("securityUnsafeParallel").setEL("WHEN(DsafeA,Dunsafe)").build();
|
||||
assertFalse(executor.execute2Future("securityUnsafeParallel",null,new MsgContext()).get(10,java.util.concurrent.TimeUnit.SECONDS).isSuccess());
|
||||
assertNull(System.getProperty("fastbee.security.liteflow"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import cn.hutool.json.JSONArray;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.hutool.core.util.NumberUtil;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//系统主题
|
||||
String sysTopic = '';
|
||||
//系统数据格式
|
||||
String sysPayload = '';
|
||||
|
||||
// 1. 获取主题和内容
|
||||
String name = msgContext.getTopic();
|
||||
Long productId = msgContext.getProductId();
|
||||
String serialNumber = msgContext.getSerialNumber();
|
||||
String protocolCode = msgContext.getProtocolCode();
|
||||
String payload = msgContext.getPayload();
|
||||
System.out.println("产品id/协议编号:" + productId + " / " + protocolCode);
|
||||
|
||||
// 2. 转换为系统主题 /96/D1ELV3A5TOJS/property/post
|
||||
sysTopic = "/" + productId + "/" + serialNumber + "/property/post"
|
||||
|
||||
if("JSON".equals(protocolCode)){
|
||||
// 3. 内容格式转换
|
||||
JSONArray newArray = new JSONArray();
|
||||
JSONObject jsonObject = JSONUtil.parseObj(payload);
|
||||
jsonObject.keySet().forEach(key -> {
|
||||
JSONObject newObject =new JSONObject();
|
||||
newObject.put("id" , key);
|
||||
newObject.put("value" , jsonObject.getStr(key));
|
||||
newArray.add(newObject);
|
||||
});
|
||||
sysPayload = newArray.toString();
|
||||
}else{
|
||||
//其他协议处理
|
||||
}
|
||||
|
||||
// 4.打印
|
||||
System.out.println("新主题:" + sysTopic);
|
||||
System.out.println("新内容:" + sysPayload);
|
||||
|
||||
// 5. 返回新的数据(必要)
|
||||
msgContext.setTopic(sysTopic);
|
||||
msgContext.setPayload(sysPayload);
|
||||
@@ -0,0 +1,19 @@
|
||||
import cn.hutool.json.JSONArray;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.hutool.core.util.NumberUtil;
|
||||
|
||||
// 1. 获取主题和内容(必要)
|
||||
String topic = msgContext.getTopic();
|
||||
String payload = msgContext.getPayload();
|
||||
|
||||
|
||||
// 2. 数据转换(自己处理)
|
||||
msgContext.logger.debug("数据转换处理")
|
||||
String NewTopic = topic;
|
||||
String NewPayload = payload;
|
||||
|
||||
|
||||
// 3. 返回新的数据(必要)
|
||||
msgContext.setTopic(NewTopic);
|
||||
msgContext.setPayload(NewPayload);
|
||||
Reference in New Issue
Block a user