Skip to content

Commit be150ef

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 33f2d43 commit be150ef

2 files changed

Lines changed: 141 additions & 4 deletions

File tree

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

Lines changed: 65 additions & 4 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;
@@ -850,9 +851,12 @@ public boolean isIndirectImportCheckEnabled() {
850851
* import statement, most usefully to prevent a class being instantiated by fully qualified name.
851852
* <p>
852853
* The rules are applied to the constructed type of a constructor call, to the receiver type of a
853-
* method call or a static method call, and to the type a method pointer or method reference is
854-
* taken on. They are not applied to every class node: class literals,
855-
* property and attribute access, cast and declaration types, and catch types are not examined. Note
854+
* method call or a static method call, to the type a method pointer or method reference is taken
855+
* on, and to the constructed type of a construction by coercion &mdash; a cast whose operand is a
856+
* list, map or closure literal ({@code (Foo) [..]}, {@code [..] as Foo}, {@code (Foo) { .. }})
857+
* and a named-argument subscript ({@code Foo[a: 1]}), both of which build an instance of the
858+
* named type. They are not applied to every class node: class literals, property and attribute
859+
* access, plain (converting) cast and declaration types, and catch types are not examined. Note
856860
* also that the receiver type is the static type of that expression, which for a dynamically typed
857861
* receiver is {@code java.lang.Object} rather than the class the call reaches at runtime.
858862
*
@@ -1546,9 +1550,23 @@ protected void assertExpressionAuthorized(final Expression expression) throws Se
15461550
Expression methodName = expr.getMethodName();
15471551
assertStaticImportIsAllowed(methodName instanceof ConstantExpression
15481552
? methodName.getText() : null, typename);
1553+
} else if (expression instanceof CastExpression expr && constructsByCoercion(expr)) {
1554+
// GROOVY-12283: a cast whose operand is a list, map or closure literal
1555+
// constructs an instance of the cast type (list/map -> constructor,
1556+
// closure -> SAM proxy) rather than converting an existing value, so it
1557+
// is checked like a constructor call. Covers `(Foo) [..]` and `[..] as Foo`.
1558+
ClassNode target = getExpressionType(expr.getType()); // array -> component
1559+
if (!ClassHelper.isPrimitiveType(target)) { // e.g. (int[]) [1, 2] has no class to check
1560+
assertImportIsAllowed(target.getName());
1561+
}
1562+
} else if (expression instanceof BinaryExpression expr && isNamedArgConstruction(expr)) {
1563+
// GROOVY-12283: `Foo[name: 'x', ..]` is a named-argument construction of
1564+
// Foo, not a subscript (map entries are not valid in a real subscript). The
1565+
// receiver type is dynamic here, so the class is named by its source text.
1566+
assertImportIsAllowed(expr.getLeftExpression().getText());
15491567
}
15501568
} catch (SecurityException e) {
1551-
throw new SecurityException("Indirect import checks prevents usage of expression", e);
1569+
throw new SecurityException("Indirect import checks prevent usage of expression: " + e.getMessage(), e);
15521570
}
15531571
}
15541572
}
@@ -1563,6 +1581,49 @@ protected ClassNode getExpressionType(ClassNode objectExpressionType) {
15631581
return objectExpressionType.isArray() ? getExpressionType(objectExpressionType.getComponentType()) : objectExpressionType;
15641582
}
15651583

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

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,4 +958,80 @@ final class SecureASTCustomizerTest {
958958
'''
959959
// no error means success
960960
}
961+
962+
// GROOVY-12283: a cast whose operand is a list/map/closure literal, and a named-argument
963+
// subscript, construct an instance of the named type rather than converting a value, so the
964+
// indirect import check applies to them exactly as it does to a constructor call.
965+
966+
@Test
967+
void testIndirectImportCheckBlocksCastAndAsListCoercion() {
968+
customizer.allowedImports = ['java.lang.String']
969+
customizer.indirectImportCheckEnabled = true
970+
def shell = new GroovyShell(configuration)
971+
// the constructor form is blocked already; the coercion forms build the same File
972+
assert hasSecurityException { shell.evaluate("new java.io.File('/etc/passwd')") }
973+
assert hasSecurityException { shell.evaluate("(java.io.File) ['/etc/passwd']") }
974+
assert hasSecurityException { shell.evaluate("['/etc/passwd'] as java.io.File") }
975+
}
976+
977+
@Test
978+
void testIndirectImportCheckBlocksClosureCoercion() {
979+
customizer.allowedImports = ['java.lang.String']
980+
customizer.indirectImportCheckEnabled = true
981+
def shell = new GroovyShell(configuration)
982+
assert hasSecurityException { shell.evaluate("(Runnable) { }") }
983+
assert hasSecurityException { shell.evaluate("{ -> } as Runnable") }
984+
}
985+
986+
@Test
987+
void testIndirectImportCheckBlocksNamedArgConstruction() {
988+
customizer.disallowedImports = ['org.codehaus.groovy.control.customizers.SecGadget']
989+
customizer.indirectImportCheckEnabled = true
990+
def shell = new GroovyShell(configuration)
991+
String g = 'org.codehaus.groovy.control.customizers.SecGadget'
992+
// entry-only and explicit-cast forms coerce to a cast of a map literal (the cast branch)
993+
assert hasSecurityException { shell.evaluate("${g}[a: 1]") }
994+
assert hasSecurityException { shell.evaluate("(${g}) [a: 1]") }
995+
// an entry mixed with a spread stays a subscript BinaryExpression (the subscript branch)
996+
assert hasSecurityException { shell.evaluate("def m = [b: 2]; ${g}[a: 1, *: m]") }
997+
}
998+
999+
@Test
1000+
void testIndirectImportCheckAllowsCoercionToPermittedType() {
1001+
customizer.allowedImports = ['java.io.File', 'java.lang.Runnable']
1002+
customizer.indirectImportCheckEnabled = true
1003+
def shell = new GroovyShell(configuration)
1004+
// the target types are permitted, so the coercions are permitted
1005+
shell.evaluate("(java.io.File) ['/tmp/x']")
1006+
shell.evaluate("['/tmp/x'] as java.io.File")
1007+
shell.evaluate("(Runnable) { }")
1008+
}
1009+
1010+
@Test
1011+
void testIndirectImportCheckLeavesInertCastsUnexamined() {
1012+
// a plain (converting) cast does not construct, so it is not checked even when its type
1013+
// is not on the allow list — this pins the slice boundary
1014+
customizer.allowedImports = ['java.util.ArrayList']
1015+
customizer.indirectImportCheckEnabled = true
1016+
def shell = new GroovyShell(configuration)
1017+
shell.evaluate("(CharSequence) 'hello'") // operand is a value, not a literal coercion
1018+
shell.evaluate("def n = 1; (Number) n")
1019+
// and a genuine positional subscript is untouched
1020+
shell.evaluate("def list = [10, 20]; list[1]")
1021+
// a primitive-array coercion has no class name to check, so it is not blocked
1022+
shell.evaluate("(int[]) [1, 2, 3]")
1023+
shell.evaluate("[1, 2, 3] as int[]")
1024+
// a pure spread subscript coerces to `m as Foo` — a variable operand, the same
1025+
// non-literal coercion residual as `var as Foo`, so it is not examined (and constructs)
1026+
shell.evaluate("def m = [x: 1]; org.codehaus.groovy.control.customizers.SecGadget[*: m]")
1027+
}
1028+
}
1029+
1030+
/**
1031+
* Helper for {@link SecureASTCustomizerTest}: a class with a map constructor, referenced by
1032+
* fully qualified name so the indirect import check applies (GROOVY-12283).
1033+
*/
1034+
class SecGadget {
1035+
String tag
1036+
SecGadget(Map m) { tag = "map:$m" }
9611037
}

0 commit comments

Comments
 (0)