Skip to content
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>For confined student code this policy:
*
* <ul>
* <li>allows read/write/delete only under the writable temp directory,
* <li>allows read-only access to the app/runtime directories the JVM and student libraries need
* (so class loading, fonts, and bundled assets keep working),
* <li>never allows execute
* <li>denies all other filesystem access
* </ul>
*
* <p>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 = "<<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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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("<<ALL FILES>>", "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);
}
}