Skip to content

Commit 86607de

Browse files
committed
GROOVY-12283: apply import rules to construction-coercion casts and subscripts
The indirect import check inspected constructor, method, static-method and method-pointer expressions, so a class forbidden by the import rules could still be built through a construction that is neither a constructor call nor a method call: a cast whose operand is a list, map or closure literal ((Foo) [..], [..] as Foo, (Runnable) { }), and a named-argument subscript (Foo[a: 1]). Each builds an instance of the named type. The check is extended to both. A cast constructs when its operand is a list, map or closure literal, as opposed to converting a value that already exists; its target type is checked like a constructor call (array component unwrapped, primitive components skipped as they name no class). A subscript constructs when its arguments are map entries, which are not valid in an ordinary subscript, so their presence marks the form unambiguously; the receiver type is dynamic at this phase, so the class is named by its source text. Plain converting casts ((String) x, (int) n) and positional subscripts stay unexamined. The residual is the non-literal coercion ((Foo) var, var as Foo), where an overridden asType could construct at runtime; that is statically invisible and out of scope, consistent with this customizer being a hardening aid rather than a security boundary.
1 parent 34ed575 commit 86607de

2 files changed

Lines changed: 144 additions & 6 deletions

File tree

src/main/java/org/codehaus/groovy/control/customizers/SecureASTCustomizer.java

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
package org.codehaus.groovy.control.customizers;
2020

2121
import org.codehaus.groovy.ast.ASTNode;
22+
import org.codehaus.groovy.ast.ClassHelper;
2223
import org.codehaus.groovy.ast.ClassNode;
2324
import org.codehaus.groovy.ast.CodeVisitorSupport;
2425
import org.codehaus.groovy.ast.ConstructorNode;
@@ -849,11 +850,15 @@ public boolean isIndirectImportCheckEnabled() {
849850
* Set this option to true to apply the import rules to types which appear in the source without an
850851
* import statement, most usefully to prevent a class being instantiated by fully qualified name.
851852
* <p>
852-
* The rules are applied to the constructed type of a constructor call, and to the receiver type of a
853-
* method call or a static method call. They are not applied to every class node: class literals,
854-
* property and attribute access, cast and declaration types, and catch types are not examined. Note
855-
* also that the receiver type is the static type of that expression, which for a dynamically typed
856-
* receiver is {@code java.lang.Object} rather than the class the call reaches at runtime.
853+
* The rules are applied to the constructed type of a constructor call, to the receiver type of a
854+
* method call or a static method call, and to the constructed type of a construction by coercion
855+
* &mdash; a cast whose operand is a list, map or closure literal ({@code (Foo) [..]},
856+
* {@code [..] as Foo}, {@code (Foo) { .. }}) and a named-argument subscript ({@code Foo[a: 1]}),
857+
* both of which build an instance of the named type. They are not applied to every class node:
858+
* class literals, property and attribute access, plain (converting) cast and declaration types,
859+
* and catch types are not examined. Note also that the receiver type is the static type of that
860+
* expression, which for a dynamically typed receiver is {@code java.lang.Object} rather than the
861+
* class the call reaches at runtime.
857862
*
858863
* @param indirectImportCheckEnabled set to true to enable indirect checks
859864
*/
@@ -1540,9 +1545,23 @@ protected void assertExpressionAuthorized(final Expression expression) throws Se
15401545
final String typename = expr.getType().getName();
15411546
assertImportIsAllowed(typename);
15421547
assertStaticImportIsAllowed(expr.getText(), typename);
1548+
} else if (expression instanceof CastExpression expr && constructsByCoercion(expr)) {
1549+
// GROOVY-12283: a cast whose operand is a list, map or closure literal
1550+
// constructs an instance of the cast type (list/map -> constructor,
1551+
// closure -> SAM proxy) rather than converting an existing value, so it
1552+
// is checked like a constructor call. Covers `(Foo) [..]` and `[..] as Foo`.
1553+
ClassNode target = getExpressionType(expr.getType()); // array -> component
1554+
if (!ClassHelper.isPrimitiveType(target)) { // e.g. (int[]) [1, 2] has no class to check
1555+
assertImportIsAllowed(target.getName());
1556+
}
1557+
} else if (expression instanceof BinaryExpression expr && isNamedArgConstruction(expr)) {
1558+
// GROOVY-12283: `Foo[name: 'x', ..]` is a named-argument construction of
1559+
// Foo, not a subscript (map entries are not valid in a real subscript). The
1560+
// receiver type is dynamic here, so the class is named by its source text.
1561+
assertImportIsAllowed(expr.getLeftExpression().getText());
15431562
}
15441563
} catch (SecurityException e) {
1545-
throw new SecurityException("Indirect import checks prevents usage of expression", e);
1564+
throw new SecurityException("Indirect import checks prevent usage of expression: " + e.getMessage(), e);
15461565
}
15471566
}
15481567
}
@@ -1557,6 +1576,49 @@ protected ClassNode getExpressionType(ClassNode objectExpressionType) {
15571576
return objectExpressionType.isArray() ? getExpressionType(objectExpressionType.getComponentType()) : objectExpressionType;
15581577
}
15591578

1579+
/**
1580+
* Whether a cast constructs an instance of its type by coercing a literal operand — a list
1581+
* or map (invoking a constructor) or a closure (creating a SAM proxy) — as opposed to
1582+
* converting a value that already exists. Such a cast is treated like a constructor call by
1583+
* the indirect import check (GROOVY-12283).
1584+
*
1585+
* @param cast the cast expression
1586+
* @return {@code true} if the cast materialises a new instance of its type
1587+
*/
1588+
private static boolean constructsByCoercion(final CastExpression cast) {
1589+
Expression operand = cast.getExpression();
1590+
return operand instanceof ListExpression
1591+
|| operand instanceof MapExpression
1592+
|| operand instanceof ClosureExpression;
1593+
}
1594+
1595+
/**
1596+
* Whether a subscript is a named-argument construction such as
1597+
* {@code Foo[name: 'x', *: extra]} rather than an ordinary index access. Map entries are
1598+
* not valid in a real subscript, so their presence uniquely marks the construction form
1599+
* (GROOVY-12283).
1600+
* <p>
1601+
* A construction with only map entries or a bare spread ({@code Foo[a: 1]}, {@code Foo[*: m]})
1602+
* reaches the customizer already coerced to a {@link CastExpression} and is handled there;
1603+
* only a form mixing entries with a spread stays a subscript, so a {@link ListExpression}
1604+
* of arguments is the case to detect here.
1605+
*
1606+
* @param expression the binary expression
1607+
* @return {@code true} if the expression constructs by named arguments
1608+
*/
1609+
private static boolean isNamedArgConstruction(final BinaryExpression expression) {
1610+
if (!"[".equals(expression.getOperation().getText())
1611+
|| !(expression.getRightExpression() instanceof ListExpression)) {
1612+
return false;
1613+
}
1614+
for (Expression element : ((ListExpression) expression.getRightExpression()).getExpressions()) {
1615+
if (element instanceof MapEntryExpression || element instanceof SpreadMapExpression) {
1616+
return true;
1617+
}
1618+
}
1619+
return false;
1620+
}
1621+
15601622
/**
15611623
* Checks that a given token is either in the allowed list or not in the disallowed list.
15621624
*

src/test/groovy/org/codehaus/groovy/control/customizers/SecureASTCustomizerTest.groovy

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,4 +919,80 @@ final class SecureASTCustomizerTest {
919919
'''
920920
// no error means success
921921
}
922+
923+
// GROOVY-12283: a cast whose operand is a list/map/closure literal, and a named-argument
924+
// subscript, construct an instance of the named type rather than converting a value, so the
925+
// indirect import check applies to them exactly as it does to a constructor call.
926+
927+
@Test
928+
void testIndirectImportCheckBlocksCastAndAsListCoercion() {
929+
customizer.allowedImports = ['java.lang.String']
930+
customizer.indirectImportCheckEnabled = true
931+
def shell = new GroovyShell(configuration)
932+
// the constructor form is blocked already; the coercion forms build the same File
933+
assert hasSecurityException { shell.evaluate("new java.io.File('/etc/passwd')") }
934+
assert hasSecurityException { shell.evaluate("(java.io.File) ['/etc/passwd']") }
935+
assert hasSecurityException { shell.evaluate("['/etc/passwd'] as java.io.File") }
936+
}
937+
938+
@Test
939+
void testIndirectImportCheckBlocksClosureCoercion() {
940+
customizer.allowedImports = ['java.lang.String']
941+
customizer.indirectImportCheckEnabled = true
942+
def shell = new GroovyShell(configuration)
943+
assert hasSecurityException { shell.evaluate("(Runnable) { }") }
944+
assert hasSecurityException { shell.evaluate("{ -> } as Runnable") }
945+
}
946+
947+
@Test
948+
void testIndirectImportCheckBlocksNamedArgConstruction() {
949+
customizer.disallowedImports = ['org.codehaus.groovy.control.customizers.SecGadget']
950+
customizer.indirectImportCheckEnabled = true
951+
def shell = new GroovyShell(configuration)
952+
String g = 'org.codehaus.groovy.control.customizers.SecGadget'
953+
// entry-only and explicit-cast forms coerce to a cast of a map literal (the cast branch)
954+
assert hasSecurityException { shell.evaluate("${g}[a: 1]") }
955+
assert hasSecurityException { shell.evaluate("(${g}) [a: 1]") }
956+
// an entry mixed with a spread stays a subscript BinaryExpression (the subscript branch)
957+
assert hasSecurityException { shell.evaluate("def m = [b: 2]; ${g}[a: 1, *: m]") }
958+
}
959+
960+
@Test
961+
void testIndirectImportCheckAllowsCoercionToPermittedType() {
962+
customizer.allowedImports = ['java.io.File', 'java.lang.Runnable']
963+
customizer.indirectImportCheckEnabled = true
964+
def shell = new GroovyShell(configuration)
965+
// the target types are permitted, so the coercions are permitted
966+
shell.evaluate("(java.io.File) ['/tmp/x']")
967+
shell.evaluate("['/tmp/x'] as java.io.File")
968+
shell.evaluate("(Runnable) { }")
969+
}
970+
971+
@Test
972+
void testIndirectImportCheckLeavesInertCastsUnexamined() {
973+
// a plain (converting) cast does not construct, so it is not checked even when its type
974+
// is not on the allow list — this pins the slice boundary
975+
customizer.allowedImports = ['java.util.ArrayList']
976+
customizer.indirectImportCheckEnabled = true
977+
def shell = new GroovyShell(configuration)
978+
shell.evaluate("(CharSequence) 'hello'") // operand is a value, not a literal coercion
979+
shell.evaluate("def n = 1; (Number) n")
980+
// and a genuine positional subscript is untouched
981+
shell.evaluate("def list = [10, 20]; list[1]")
982+
// a primitive-array coercion has no class name to check, so it is not blocked
983+
shell.evaluate("(int[]) [1, 2, 3]")
984+
shell.evaluate("[1, 2, 3] as int[]")
985+
// a pure spread subscript coerces to `m as Foo` — a variable operand, the same
986+
// non-literal coercion residual as `var as Foo`, so it is not examined (and constructs)
987+
shell.evaluate("def m = [x: 1]; org.codehaus.groovy.control.customizers.SecGadget[*: m]")
988+
}
989+
}
990+
991+
/**
992+
* Helper for {@link SecureASTCustomizerTest}: a class with a map constructor, referenced by
993+
* fully qualified name so the indirect import check applies (GROOVY-12283).
994+
*/
995+
class SecGadget {
996+
String tag
997+
SecGadget(Map m) { tag = "map:$m" }
922998
}

0 commit comments

Comments
 (0)