diff --git a/org-code-javabuilder/lib/src/main/java/org/code/javabuilder/JavabuilderSecurityPolicy.java b/org-code-javabuilder/lib/src/main/java/org/code/javabuilder/JavabuilderSecurityPolicy.java new file mode 100644 index 00000000..55188cb3 --- /dev/null +++ b/org-code-javabuilder/lib/src/main/java/org/code/javabuilder/JavabuilderSecurityPolicy.java @@ -0,0 +1,142 @@ +package org.code.javabuilder; + +import java.io.File; +import java.io.FilePermission; +import java.net.URL; +import java.security.CodeSource; +import java.security.Permission; +import java.security.Policy; +import java.security.ProtectionDomain; +import java.security.cert.Certificate; +import java.util.List; + +/** + * Security policy that confines student code at runtime while leaving all framework, JDK, and AWS + * SDK code fully trusted. Student code is identified by being loaded from a {@link + * UserClassLoader}. This covers both standard USER runs and the VALIDATOR-level validation run, + * which executes student code (the student's main method) within the same class loader. + * + *

For confined student code this policy: + * + *

+ * + *

NOTE: {@link SecurityManager} / {@link Policy} are deprecated in Java 17 and removed in Java + * 21. This control is valid on the current java11 runtime only. + */ +public class JavabuilderSecurityPolicy extends Policy { + private static final String ALL_FILES = "<>"; + + // The only root confined student code may write to / delete within. + private final String writableRoot; + // Roots confined student code may read from (a superset of writableRoot). Everything the JVM + // needs to load classes, fonts, and library assets lives under these + private final String[] readableRoots; + + public JavabuilderSecurityPolicy() { + this.writableRoot = canonicalize(System.getProperty("java.io.tmpdir")); + this.readableRoots = + new String[] { + this.writableRoot, + canonicalize(System.getProperty("java.home")), // JRE: class/resource loading + "/var/task", // deployed application code + dependency jars + "/var/lang", // managed runtime + "/opt" // Lambda layers: fonts, instrument samples, wrapper + }; + // Warm up here, at construction, so it is guaranteed to run before the caller installs a + // SecurityManager. + warmUp(); + } + + @Override + public boolean implies(ProtectionDomain domain, Permission permission) { + if (!isConfinedStudentCode(domain)) { + // Framework / JDK / AWS SDK code: full trust. + return true; + } + if (permission instanceof FilePermission) { + return isAllowedFileAccess((FilePermission) permission); + } + return true; + } + + /** + * Forces every class that #implies references to load. Called from the constructor so it always + * runs before a SecurityManager is installed. + * + * #implies runs on every permission check, including the File.exists() calls + * the class loader makes to locate a class. If a class #implies references + * is still unloaded when the first such check fires, loading it re-enters + * #implies before the load completes and throws ClassCircularityError. Exercising + * the policy here, while no SecurityManager is active, guarantees those classes are already + * loaded by the time checks begin. + */ + private void warmUp() { + final ProtectionDomain studentDomain = + new ProtectionDomain( + new CodeSource(null, (Certificate[]) null), + null, + new UserClassLoader( + new URL[] {}, + ClassLoader.getSystemClassLoader(), + List.of(), + RunPermissionLevel.USER), + null); + this.implies(studentDomain, new FilePermission("warmup", "read")); + this.implies(studentDomain, new FilePermission("warmup", "write")); + } + + private boolean isConfinedStudentCode(ProtectionDomain domain) { + // Confine any code loaded by a UserClassLoader. This covers both standard USER runs and the + // VALIDATOR-level validation run: validation loads and invokes the student's code (via the + // student's main method) in the same class loader, so this ensures student code is confined + // there too, not just in RUN and user-test modes. + return domain != null && domain.getClassLoader() instanceof UserClassLoader; + } + + private boolean isAllowedFileAccess(FilePermission permission) { + if (ALL_FILES.equals(permission.getName())) { + return false; + } + final String path = canonicalize(permission.getName()); + if (path == null) { + return false; + } + final String actions = permission.getActions(); + if (actions.contains("execute")) { + return false; + } + if (actions.contains("write") || actions.contains("delete")) { + return isUnder(path, this.writableRoot); + } + // read / readlink + for (String root : this.readableRoots) { + if (root != null && isUnder(path, root)) { + return true; + } + } + return false; + } + + private static boolean isUnder(String path, String root) { + return path.equals(root) || path.startsWith(root + File.separator); + } + + private static String canonicalize(String path) { + if (path == null) { + return null; + } + try { + // getCanonicalPath() does not itself trigger a SecurityManager check, so this does not + // recurse; resolving symlinks prevents a /tmp/link -> /etc escape. + return new File(path).getCanonicalPath(); + } catch (Exception e) { + return null; + } + } +} diff --git a/org-code-javabuilder/lib/src/main/java/org/code/javabuilder/LambdaRequestHandler.java b/org-code-javabuilder/lib/src/main/java/org/code/javabuilder/LambdaRequestHandler.java index b9c86a0a..c70f0360 100644 --- a/org-code-javabuilder/lib/src/main/java/org/code/javabuilder/LambdaRequestHandler.java +++ b/org-code-javabuilder/lib/src/main/java/org/code/javabuilder/LambdaRequestHandler.java @@ -20,6 +20,7 @@ import java.io.File; import java.io.IOException; import java.nio.file.Paths; +import java.security.Policy; import java.time.Clock; import java.time.Instant; import java.util.List; @@ -74,6 +75,13 @@ public LambdaRequestHandler() { // This will only be called once in the initial creation of the lambda instance. // Documentation: https://docs.aws.amazon.com/lambda/latest/dg/java-handler.html CachedResources.create(); + + // Install the security policy once for the entire container. The policy scopes itself to code + // loaded by a UserClassLoader, so a fresh UserClassLoader per run means confinement is applied + // per run without any per-invocation setup. + final JavabuilderSecurityPolicy securityPolicy = new JavabuilderSecurityPolicy(); + Policy.setPolicy(securityPolicy); + System.setSecurityManager(new SecurityManager()); COLD_BOOT_END = Clock.systemUTC().instant(); this.apiClient = AmazonApiGatewayManagementApiClientBuilder.standard() diff --git a/org-code-javabuilder/lib/src/test/java/org/code/javabuilder/JavabuilderSecurityPolicyTest.java b/org-code-javabuilder/lib/src/test/java/org/code/javabuilder/JavabuilderSecurityPolicyTest.java new file mode 100644 index 00000000..825d8dd0 --- /dev/null +++ b/org-code-javabuilder/lib/src/test/java/org/code/javabuilder/JavabuilderSecurityPolicyTest.java @@ -0,0 +1,111 @@ +package org.code.javabuilder; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.FilePermission; +import java.net.URL; +import java.security.CodeSource; +import java.security.ProtectionDomain; +import java.security.cert.Certificate; +import java.util.List; +import java.util.PropertyPermission; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class JavabuilderSecurityPolicyTest { + private JavabuilderSecurityPolicy policy; + private ProtectionDomain userDomain; + private ProtectionDomain validatorDomain; + private ProtectionDomain frameworkDomain; + + @BeforeEach + public void setUp() { + policy = new JavabuilderSecurityPolicy(); + userDomain = domainFor(userClassLoader(RunPermissionLevel.USER)); + validatorDomain = domainFor(userClassLoader(RunPermissionLevel.VALIDATOR)); + frameworkDomain = domainFor(JavabuilderSecurityPolicyTest.class.getClassLoader()); + } + + @Test + public void studentCanReadAndWriteUnderTmp() { + final String tmpFile = System.getProperty("java.io.tmpdir") + "/student-output.txt"; + assertTrue(policy.implies(userDomain, new FilePermission(tmpFile, "read"))); + assertTrue(policy.implies(userDomain, new FilePermission(tmpFile, "write"))); + assertTrue(policy.implies(userDomain, new FilePermission(tmpFile, "delete"))); + } + + @Test + public void studentCannotReadOutsideAllowedRoots() { + assertFalse(policy.implies(userDomain, new FilePermission("/etc/passwd", "read"))); + assertFalse(policy.implies(userDomain, new FilePermission("/proc/self/environ", "read"))); + assertFalse(policy.implies(userDomain, new FilePermission("<>", "read"))); + } + + @Test + public void studentCannotWriteOutsideTmp() { + // java.home is readable but not writable for student code. + final String runtimeFile = System.getProperty("java.home") + "/lib/evil.jar"; + assertTrue(policy.implies(userDomain, new FilePermission(runtimeFile, "read"))); + assertFalse(policy.implies(userDomain, new FilePermission(runtimeFile, "write"))); + } + + @Test + public void studentCanReadRuntimeDirectoriesForClassLoading() { + final String runtimeFile = System.getProperty("java.home") + "/lib/modules"; + assertTrue(policy.implies(userDomain, new FilePermission(runtimeFile, "read"))); + } + + @Test + public void studentRetainsNonFilePermissions() { + // Filesystem access is the only thing confined; other capabilities are preserved. getenv is + // intentionally NOT denied because the AWS SDK resolves credentials via getenv while student + // code is on the call stack (e.g. flushing a println through the output adapter). + assertTrue(policy.implies(userDomain, new RuntimePermission("modifyThread"))); + assertTrue(policy.implies(userDomain, new PropertyPermission("user.language", "read"))); + assertTrue(policy.implies(userDomain, new RuntimePermission("getenv.AWS_SECRET_ACCESS_KEY"))); + } + + @Test + public void studentCannotExecuteEvenUnderTmp() { + final String tmpBinary = System.getProperty("java.io.tmpdir") + "/x"; + assertFalse(policy.implies(userDomain, new FilePermission(tmpBinary, "execute"))); + assertFalse(policy.implies(userDomain, new FilePermission(tmpBinary, "read,execute"))); + // Execute is denied everywhere, including the otherwise-readable runtime roots. + final String runtimeBinary = System.getProperty("java.home") + "/bin/java"; + assertFalse(policy.implies(userDomain, new FilePermission(runtimeBinary, "execute"))); + assertFalse(policy.implies(validatorDomain, new FilePermission(tmpBinary, "execute"))); + } + + @Test + public void validatorRunIsAlsoConfined() { + // The validation run loads and executes student code in a VALIDATOR-level UserClassLoader, so + // it must be confined the same way as a USER run. + final String tmpFile = System.getProperty("java.io.tmpdir") + "/validation-output.txt"; + assertTrue(policy.implies(validatorDomain, new FilePermission(tmpFile, "write"))); + assertFalse(policy.implies(validatorDomain, new FilePermission("/etc/passwd", "read"))); + } + + @Test + public void constructorWarmUpDoesNotThrowAndPolicyStillEnforces() { + // Construction warms the policy up; it must not throw and must not weaken enforcement. + final JavabuilderSecurityPolicy freshPolicy = assertDoesNotThrow(JavabuilderSecurityPolicy::new); + assertFalse(freshPolicy.implies(userDomain, new FilePermission("/etc/passwd", "read"))); + } + + @Test + public void frameworkCodeIsFullyTrusted() { + assertTrue(policy.implies(frameworkDomain, new FilePermission("/etc/passwd", "read,write"))); + assertTrue( + policy.implies(frameworkDomain, new RuntimePermission("getenv.AWS_SECRET_ACCESS_KEY"))); + } + + private UserClassLoader userClassLoader(RunPermissionLevel level) { + return new UserClassLoader( + new URL[] {}, JavabuilderSecurityPolicyTest.class.getClassLoader(), List.of(), level); + } + + private ProtectionDomain domainFor(ClassLoader classLoader) { + return new ProtectionDomain( + new CodeSource(null, (Certificate[]) null), null, classLoader, null); + } +}