CVE-2021-3856: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: ClassLoaderTheme and ClasspathThemeResourceProviderFactory allows reading any file available as a resource to the classloader. By sending requests for theme resources with a relative path from an external HTTP client, the client will receive the content of random files if available.
CWE description: The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
CVSS v3 base score: 4.3
Published: 2022-08-26T16:15Z
Repository: https://github.com/keycloak/keycloak
Language: Java
Files changed: services/src/main/java/org/keycloak/theme/ClassLoaderTheme.java, services/src/main/java/org/keycloak/theme/ClasspathThemeResourceProviderFactory.java, testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/theme/ThemeResourceProviderTest.java
Commit date: 2021-10-18 10:23:06 +0200
Commit message: [KEYCLOAK-19422] ClassLoaderTheme and ClasspathThemeResourceProviderFactory allows reading any file available as a resource to the classloader

Diff: diff --git a/services/src/main/java/org/keycloak/theme/ClassLoaderTheme.java b/services/src/main/java/org/keycloak/theme/ClassLoaderTheme.java
index 82ad64a5842..7c013f74222 100644
--- a/services/src/main/java/org/keycloak/theme/ClassLoaderTheme.java
+++ b/services/src/main/java/org/keycloak/theme/ClassLoaderTheme.java
@@ -103,12 +103,23 @@ public class ClassLoaderTheme implements Theme {
     public URL getTemplate(String name) {
         return classLoader.getResource(templateRoot + name);
     }
 
     @Override
-    public InputStream getResourceAsStream(String path) {
-        return classLoader.getResourceAsStream(resourceRoot + path);
+    public InputStream getResourceAsStream(String path) throws IOException {
+        final URL rootResourceURL = classLoader.getResource(resourceRoot);
+        if (rootResourceURL == null) {
+            return null;
+        }
+        final String rootPath = rootResourceURL.getPath();
+        final URL resourceURL = classLoader.getResource(resourceRoot + path);
+        if(resourceURL == null || !resourceURL.getPath().startsWith(rootPath)) {
+            return null;
+        }
+        else {
+            return resourceURL.openConnection().getInputStream();
+        }
     }
 
     @Override
     public Properties getMessages(Locale locale) throws IOException {
         return getMessages("messages", locale);
diff --git a/services/src/main/java/org/keycloak/theme/ClasspathThemeResourceProviderFactory.java b/services/src/main/java/org/keycloak/theme/ClasspathThemeResourceProviderFactory.java
index 6af4073f2d1..1536afcadbe 100644
--- a/services/src/main/java/org/keycloak/theme/ClasspathThemeResourceProviderFactory.java
+++ b/services/src/main/java/org/keycloak/theme/ClasspathThemeResourceProviderFactory.java
@@ -37,11 +37,22 @@ public class ClasspathThemeResourceProviderFactory implements ThemeResourceProvi
         return classLoader.getResource(THEME_RESOURCES_TEMPLATES + name);
     }
 
     @Override
     public InputStream getResourceAsStream(String path) throws IOException {
-        return classLoader.getResourceAsStream(THEME_RESOURCES_RESOURCES + path);
+        final URL rootResourceURL = classLoader.getResource(THEME_RESOURCES_RESOURCES);
+        if (rootResourceURL == null) {
+            return null;
+        }
+        final String rootPath = rootResourceURL.getPath();
+        final URL resourceURL = classLoader.getResource(THEME_RESOURCES_RESOURCES + path);
+        if(resourceURL == null || !resourceURL.getPath().startsWith(rootPath)) {
+            return null;
+        }
+        else {
+            return resourceURL.openConnection().getInputStream();
+        }
     }
 
     @Override
     public Properties getMessages(String baseBundlename, Locale locale) throws IOException {
         Properties m = new Properties();
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/theme/ThemeResourceProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/theme/ThemeResourceProviderTest.java
index 24026dac0d6..f77355d848d 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/theme/ThemeResourceProviderTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/theme/ThemeResourceProviderTest.java
@@ -71,10 +71,22 @@ public class ThemeResourceProviderTest extends AbstractTestRealmKeycloakTest {
                 Assert.fail(e.getMessage());
             }
         });
     }
 
+    @Test
+    public void getResourceIllegalTraversal() {
+        testingClient.server().run(session -> {
+            try {
+                Theme theme = session.theme().getTheme("base", Theme.Type.LOGIN);
+                Assert.assertNull(theme.getResourceAsStream("../templates/test.ftl"));
+            } catch (IOException e) {
+                Assert.fail(e.getMessage());
+            }
+        });
+    }
+
     @Test
     public void gzipEncoding() throws IOException {
         final String resourcesVersion = testingClient.server().fetch(session -> Version.RESOURCES_VERSION, String.class);
 
         // This will return true if files did not exists before the test OR they did exists, but were successfully deleted.

Vulnerable code:     public InputStream getResourceAsStream(String path) {
        return classLoader.getResourceAsStream(resourceRoot + path);
        return classLoader.getResourceAsStream(THEME_RESOURCES_RESOURCES + path);
Fixed code:     public InputStream getResourceAsStream(String path) throws IOException {
        final URL rootResourceURL = classLoader.getResource(resourceRoot);
        if (rootResourceURL == null) {
            return null;
        }
        final String rootPath = rootResourceURL.getPath();
        final URL resourceURL = classLoader.getResource(resourceRoot + path);
        if(resourceURL == null || !resourceURL.getPath().startsWith(rootPath)) {
            return null;
        }
        else {
            return resourceURL.openConnection().getInputStream();
        }
        final URL rootResourceURL = classLoader.getResource(THEME_RESOURCES_RESOURCES);
        if (rootResourceURL == null) {
            return null;
        }
        final String rootPath = rootResourceURL.getPath();
        final URL resourceURL = classLoader.getResource(THEME_RESOURCES_RESOURCES + path);
        if(resourceURL == null || !resourceURL.getPath().startsWith(rootPath)) {
            return null;
        }
        else {
            return resourceURL.openConnection().getInputStream();
        }
    @Test
    public void getResourceIllegalTraversal() {
        testingClient.server().run(session -> {
            try {
                Theme theme = session.theme().getTheme("base", Theme.Type.LOGIN);
                Assert.assertNull(theme.getResourceAsStream("../templates/test.ftl"));
            } catch (IOException e) {
                Assert.fail(e.getMessage());
            }
        });
    }