Tuesday, June 27, 2023

Java's SecurityManager in the age of virtual threads

The JDK-21 is just around the corner, bringing the virtual threads (JEP 444: Virtual Threads) to the mainstream, at least this is the plan so far. For vast majority of the projects out there the virtual threads open up a huge number of opportunities but for some - add new headaches.

More specifically, let us talk about niche projects that still rely on SecurityManager. The SecurityManager has been deprecated for removal in JDK-17 (JEP 411: Deprecate the Security Manager for Removal) and disallowed in JDK-18 (JDK-8270380). Its usage is discouraged but it is still there and is used in production systems.

Since JDK-21 early builds are available to everyone (as of this moment, the latest build is 21-ea+28-2377), let us find out what is happening when SecurityManager meets virtual threads. The code snippet below would serve as an example of the application that installs the SecurityManager and tries to fetch, well, the content of JEP 444, for sake of doing network calls. In the first attempt, we are going to use standard thread pool (operating system threads).

public class Starter {
    public static void main(String[] args) throws Exception {
        System.setSecurityManager(new SecurityManager());
        try (var executor = Executors.newSingleThreadExecutor()) {
            var future = executor.submit(() -> fetch("https://openjdk.org/jeps/444"));
            System.out.println(future.get());
        }
    }

    private static String fetch(String url) throws MalformedURLException, IOException, URISyntaxException {
        try (var in = new URI(url).toURL().openStream()) {
            return new String(in.readAllBytes(), StandardCharsets.UTF_8);
        }
    }
}

If we run this example using just java (thanks to JEP 330: Launch Single-File Source-Code Programs), it is going to fail with the java.security.AccessControlException:

$ java -Djava.security.manager=allow src/main/java/com/example/Starter.java

...
Exception in thread "main" java.util.concurrent.ExecutionException: java.security.AccessControlException: access denied ("java.net.SocketPermission" "openjdk.org:443" "connect,resolve")
        at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:122)
        at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:191)
        at com.example.Starter.main(Starter.java:15)
        Suppressed: java.security.AccessControlException: access denied ("java.lang.RuntimePermission" "modifyThread")
                at java.base/java.security.AccessControlContext.checkPermission(AccessControlContext.java:488)
                at java.base/java.security.AccessController.checkPermission(AccessController.java:1071)
                at java.base/java.lang.SecurityManager.checkPermission(SecurityManager.java:411)
                at java.base/java.util.concurrent.ThreadPoolExecutor.checkShutdownAccess(ThreadPoolExecutor.java:764)
                at java.base/java.util.concurrent.ThreadPoolExecutor.shutdown(ThreadPoolExecutor.java:1394)
                at java.base/java.util.concurrent.Executors$DelegatedExecutorService.shutdown(Executors.java:759)
                at java.base/java.util.concurrent.Executors$AutoShutdownDelegatedExecutorService.shutdown(Executors.java:846)
                at java.base/java.util.concurrent.ExecutorService.close(ExecutorService.java:413)
                at com.example.Starter.main(Starter.java:13)
                at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
                at java.base/java.lang.reflect.Method.invoke(Method.java:580)
                at jdk.compiler/com.sun.tools.javac.launcher.Main.execute(Main.java:484)
                at jdk.compiler/com.sun.tools.javac.launcher.Main.run(Main.java:208)
                at jdk.compiler/com.sun.tools.javac.launcher.Main.main(Main.java:135)
Caused by: java.security.AccessControlException: access denied ("java.net.SocketPermission" "openjdk.org:443" "connect,resolve")
        at java.base/java.security.AccessControlContext.checkPermission(AccessControlContext.java:488)
        at java.base/java.security.AccessController.checkPermission(AccessController.java:1071)
        at java.base/java.lang.SecurityManager.checkPermission(SecurityManager.java:411)
        at java.base/java.lang.SecurityManager.checkConnect(SecurityManager.java:905)
        at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:619)
        at java.base/sun.net.www.protocol.https.HttpsClient.<init>(HttpsClient.java:264)
        at java.base/sun.net.www.protocol.https.HttpsClient.New(HttpsClient.java:377)
        at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.getNewHttpClient(AbstractDelegateHttpsURLConnection.java:193)
        at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect0(HttpURLConnection.java:1237)
        at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect(HttpURLConnection.java:1123)
        at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.connect(AbstractDelegateHttpsURLConnection.java:179)
        at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1675)
        at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1599)
        at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.getInputStream(HttpsURLConnectionImpl.java:223)
        at java.base/java.net.URL.openStream(URL.java:1325)
        at com.example.Starter.fetch(Starter.java:24)
        at com.example.Starter.lambda$main$0(Starter.java:14)
        at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
        at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
        at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
        at java.base/java.lang.Thread.run(Thread.java:1583)

That is kind of expected, we need to craft the policy to allow the connection over socket to openjdk.org host, the minimal one we need is below, stored in src/main/resources/security.policy:

grant { 
    permission java.lang.RuntimePermission "modifyThread";
    permission java.net.SocketPermission "openjdk.org:443", "connect,resolve";
};

Now, if we rerun this example with this policy, it should print out the content of the JEP into the console (in HTML format):

$ java -Djava.security.manager=allow  -Djava.security.policy=src/main/resources/security.policy src/main/java/com/example/Starter.java

...
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=us-ascii" />
...

Awesome, so let just switch over to virtual threads! It should just work, right?

public class Starter {
    public static void main(String[] args) throws Exception {
        System.setSecurityManager(new SecurityManager());
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var future = executor.submit(() -> fetch("https://openjdk.org/jeps/444"));
            System.out.println(future.get());
        }
    }

    private static String fetch(String url) throws MalformedURLException, IOException, URISyntaxException {
        try (var in = new URI(url).toURL().openStream()) {
            return new String(in.readAllBytes(), StandardCharsets.UTF_8);
        }
    }
}

Or should it?

$ java -Djava.security.manager=allow  -Djava.security.policy=src/main/resources/security.policy src/main/java/com/example/Starter.java

...
Exception in thread "main" java.util.concurrent.ExecutionException: java.security.AccessControlException: access denied ("java.net.SocketPermission" "openjdk.org:443" "connect,resolve")
        at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:122)
        at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:191)
        at com.example.Starter.main(Starter.java:15)
Caused by: java.security.AccessControlException: access denied ("java.net.SocketPermission" "openjdk.org:443" "connect,resolve")
        at java.base/java.security.AccessControlContext.checkPermission(AccessControlContext.java:488)
        at java.base/java.security.AccessController.checkPermission(AccessController.java:1071)
        at java.base/java.lang.SecurityManager.checkPermission(SecurityManager.java:411)
        at java.base/java.lang.SecurityManager.checkConnect(SecurityManager.java:905)
        at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:619)
        at java.base/sun.net.www.protocol.https.HttpsClient.<init>(HttpsClient.java:264)
        at java.base/sun.net.www.protocol.https.HttpsClient.New(HttpsClient.java:377)
        at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.getNewHttpClient(AbstractDelegateHttpsURLConnection.java:193)
        at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect0(HttpURLConnection.java:1237)
        at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect(HttpURLConnection.java:1123)
        at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.connect(AbstractDelegateHttpsURLConnection.java:179)
        at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1675)
        at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1599)
        at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.getInputStream(HttpsURLConnectionImpl.java:223)
        at java.base/java.net.URL.openStream(URL.java:1325)
        at com.example.Starter.fetch(Starter.java:20)
        at com.example.Starter.lambda$main$0(Starter.java:14)
        at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
        at java.base/java.lang.VirtualThread.run(VirtualThread.java:311)

If you are surprised, so was I. But we really shouldn't be if we read JEP 444: Virtual Threads carefully enough. It says clearly:

  • Virtual threads have no permissions when running with a SecurityManager set.

Why is that? Back in the days, the rumors were being spread that Project Loom was one of the reasons to kick SecurityManager out, the two didn't play well together. True or not, here we are.

For better or worse, the SecurityManager has transitioned from being the source of annoying deprecation warnings to rather a serious obstacle on the route of adopting recent JDK features. The time of making hard decisions is approaching very fast.

I πŸ‡ΊπŸ‡¦ stand πŸ‡ΊπŸ‡¦ with πŸ‡ΊπŸ‡¦ Ukraine.