]> source.dussan.org Git - vaadin-framework.git/commitdiff
fix: don't serve directories as static files (#12325)
authorAnna Koskinen <Ansku@users.noreply.github.com>
Wed, 23 Jun 2021 12:29:23 +0000 (15:29 +0300)
committerGitHub <noreply@github.com>
Wed, 23 Jun 2021 12:29:23 +0000 (15:29 +0300)
Also prevents opening FileSystem for unknown schemes.

Modified cherry-picks of https://github.com/vaadin/flow/pull/11072 ,
https://github.com/vaadin/flow/pull/11147 , and
https://github.com/vaadin/flow/pull/11235

server/src/main/java/com/vaadin/server/VaadinServlet.java
server/src/main/java/com/vaadin/server/VaadinServletService.java
server/src/test/java/com/vaadin/server/VaadinServletTest.java
server/src/test/java/com/vaadin/server/WarURLStreamHandlerFactory.java [new file with mode: 0644]

index 58320ecb0a9268a4bd4435d35a1110690df2d49d..3ddbd956e93cf22effbe220fe9e1fec2427d4029 100644 (file)
@@ -31,15 +31,22 @@ import java.io.Serializable;
 import java.io.UnsupportedEncodingException;
 import java.lang.reflect.Method;
 import java.net.MalformedURLException;
+import java.net.URI;
 import java.net.URISyntaxException;
 import java.net.URL;
 import java.net.URLConnection;
 import java.net.URLDecoder;
 import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.Enumeration;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -190,6 +197,10 @@ public class VaadinServlet extends HttpServlet implements Constants {
 
     private VaadinServletService servletService;
 
+    // Mapped uri is for the jar file
+    static final Map<URI, Integer> openFileSystems = new HashMap<>();
+    private static final Object fileSystemLock = new Object();
+
     /**
      * Called by the servlet container to indicate to a servlet that the servlet
      * is being placed into service.
@@ -842,10 +853,12 @@ public class VaadinServlet extends HttpServlet implements Constants {
         }
 
         // security check: do not permit navigation out of the VAADIN
-        // directory
+        // directory or into any directory rather than a file
         if (!isAllowedVAADINResourceUrl(request, resourceUrl)) {
             getLogger().log(Level.INFO,
-                    "Requested resource [{0}] not accessible in the VAADIN directory or access to it is forbidden.",
+                    "Requested resource [{0}] is a directory, "
+                            + "is not within the VAADIN directory, "
+                            + "or access to it is forbidden.",
                     filename);
             response.setStatus(HttpServletResponse.SC_FORBIDDEN);
             return;
@@ -1094,7 +1107,9 @@ public class VaadinServlet extends HttpServlet implements Constants {
         // directory
         if (!isAllowedVAADINResourceUrl(request, scssUrl)) {
             getLogger().log(Level.INFO,
-                    "Requested resource [{0}] not accessible in the VAADIN directory or access to it is forbidden.",
+                    "Requested resource [{0}] is a directory, "
+                            + "is not within the VAADIN directory, "
+                            + "or access to it is forbidden.",
                     filename);
             response.setStatus(HttpServletResponse.SC_FORBIDDEN);
 
@@ -1203,7 +1218,8 @@ public class VaadinServlet extends HttpServlet implements Constants {
 
     /**
      * Check whether a URL obtained from a classloader refers to a valid static
-     * resource in the directory VAADIN.
+     * resource in the directory VAADIN. Directories do not count as valid
+     * resources.
      *
      * Warning: Overriding of this method is not recommended, but is possible to
      * support non-default classloaders or servers that may produce URLs
@@ -1223,6 +1239,9 @@ public class VaadinServlet extends HttpServlet implements Constants {
     @Deprecated
     protected boolean isAllowedVAADINResourceUrl(HttpServletRequest request,
             URL resourceUrl) {
+        if (resourceUrl == null || resourceIsDirectory(resourceUrl)) {
+            return false;
+        }
         String resourcePath = resourceUrl.getPath();
         if ("jar".equals(resourceUrl.getProtocol())) {
             // This branch is used for accessing resources directly from the
@@ -1263,6 +1282,124 @@ public class VaadinServlet extends HttpServlet implements Constants {
         }
     }
 
+    private boolean resourceIsDirectory(URL resource) {
+        if (resource.getPath().endsWith("/")) {
+            return true;
+        }
+        URI resourceURI = null;
+        try {
+            resourceURI = resource.toURI();
+        } catch (URISyntaxException e) {
+            getLogger().log(Level.FINE,
+                    "Syntax error in uri from getStaticResource", e);
+            // Return false as we couldn't determine if the resource is a
+            // directory.
+            return false;
+        }
+
+        if ("jar".equals(resource.getProtocol())) {
+            // Get the file path in jar
+            String pathInJar = resource.getPath()
+                    .substring(resource.getPath().indexOf('!') + 1);
+            try {
+                FileSystem fileSystem = getFileSystem(resourceURI);
+                // Get the file path inside the jar.
+                Path path = fileSystem.getPath(pathInJar);
+
+                return Files.isDirectory(path);
+            } catch (IOException e) {
+                getLogger().log(Level.FINE, "failed to read jar file contents",
+                        e);
+            } finally {
+                closeFileSystem(resourceURI);
+            }
+        }
+
+        // If not a jar check if a file path directory.
+        return "file".equals(resource.getProtocol())
+                && Files.isDirectory(Paths.get(resourceURI));
+    }
+
+    /**
+     * Get the file URI for the resource jar file. Returns give URI if
+     * URI.scheme is not of type jar.
+     *
+     * The URI for a file inside a jar is composed as
+     * 'jar:file://...pathToJar.../jarFile.jar!/pathToFile'
+     *
+     * the first step strips away the initial scheme 'jar:' leaving us with
+     * 'file://...pathToJar.../jarFile.jar!/pathToFile' from which we remove the
+     * inside jar path giving the end result
+     * 'file://...pathToJar.../jarFile.jar'
+     *
+     * @param resourceURI
+     *            resource URI to get file URI for
+     * @return file URI for resource jar or given resource if not a jar schemed
+     *         URI
+     */
+    private URI getFileURI(URI resourceURI) {
+        if (!"jar".equals(resourceURI.getScheme())) {
+            return resourceURI;
+        }
+        try {
+            String scheme = resourceURI.getRawSchemeSpecificPart();
+            int jarPartIndex = scheme.indexOf("!/");
+            if (jarPartIndex != -1) {
+                scheme = scheme.substring(0, jarPartIndex);
+            }
+            return new URI(scheme);
+        } catch (URISyntaxException syntaxException) {
+            throw new IllegalArgumentException(syntaxException.getMessage(),
+                    syntaxException);
+        }
+    }
+
+    // Package protected for feature verification purpose
+    FileSystem getFileSystem(URI resourceURI) throws IOException {
+        synchronized (fileSystemLock) {
+            URI fileURI = getFileURI(resourceURI);
+            if (!fileURI.getScheme().equals("file")) {
+                throw new IOException("Can not read scheme '"
+                        + fileURI.getScheme() + "' for resource " + resourceURI
+                        + " and will determine this as not a folder");
+            }
+
+            Integer locks = openFileSystems.computeIfPresent(fileURI,
+                    (key, value) -> value + 1);
+            if (locks != null) {
+                // Get filesystem is for the file to get the correct provider
+                return FileSystems.getFileSystem(resourceURI);
+            }
+            // Opened filesystem is for the file to get the correct provider
+            FileSystem fileSystem = FileSystems.newFileSystem(resourceURI,
+                    Collections.emptyMap());
+            openFileSystems.put(fileURI, 1);
+            return fileSystem;
+        }
+    }
+
+    // Package protected for feature verification purpose
+    void closeFileSystem(URI resourceURI) {
+        synchronized (fileSystemLock) {
+            try {
+                URI fileURI = getFileURI(resourceURI);
+                Integer locks = openFileSystems.computeIfPresent(fileURI,
+                        (key, value) -> value - 1);
+                if (locks != null && locks == 0) {
+                    openFileSystems.remove(fileURI);
+                    // Get filesystem is for the file to get the correct
+                    // provider
+                    FileSystems.getFileSystem(resourceURI).close();
+                }
+            } catch (IOException ioe) {
+                getLogger().log(Level.SEVERE,
+                        "Failed to close FileSystem for '{}'", resourceURI);
+                getLogger().log(Level.INFO, "Exception closing FileSystem",
+                        ioe);
+            }
+        }
+    }
+
     /**
      * Checks if the browser has an up to date cached version of requested
      * resource. Currently the check is performed using the "If-Modified-Since"
@@ -1357,6 +1494,9 @@ public class VaadinServlet extends HttpServlet implements Constants {
     /**
      * Returns the relative path at which static files are served for a request
      * (if any).
+     * <p>
+     * NOTE: This method does not check whether the requested resource is a
+     * directory and as such not a valid VAADIN resource.
      *
      * @param request
      *            HTTP request
index efb613ceb6ec250566de5612156ceadcc5c89161..d171562180b8072881f1729869aaeae82db29fd7 100644 (file)
@@ -54,7 +54,7 @@ public class VaadinServletService extends VaadinService {
      * @since 8.2
      */
     protected VaadinServletService() {
-        this.servlet = null;
+        servlet = null;
     }
 
     @Override
@@ -249,9 +249,12 @@ public class VaadinServletService extends VaadinService {
             // security check: do not permit navigation out of the VAADIN
             // directory
             if (!getServlet().isAllowedVAADINResourceUrl(null, resourceUrl)) {
-                throw new IOException(String.format(
-                        "Requested resource [{0}] not accessible in the VAADIN directory or access to it is forbidden.",
-                        filename));
+                throw new IOException(
+                        String.format(
+                                "Requested resource [%s] is a directory, "
+                                        + "is not within the VAADIN directory, "
+                                        + "or access to it is forbidden.",
+                                filename));
             }
 
             return resourceUrl.openStream();
index 652dc3066506a9124997dee10c096eb269afecb5..006872b2128e763548339fb122c05b2427d1aa76 100644 (file)
@@ -1,15 +1,54 @@
 package com.vaadin.server;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
+import java.io.File;
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.FileSystemNotFoundException;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import javax.servlet.ServletException;
 import javax.servlet.http.HttpServletRequest;
 
+import org.junit.After;
+import org.junit.Assert;
 import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
 import org.mockito.Mockito;
 
+/**
+ * Tests for {@link VaadinServlet}.
+ * <p>
+ * NOTE: some of these tests are not thread safe. Add
+ * {@code @net.jcip.annotations.NotThreadSafe} to this class if this module is
+ * converted to use {@code maven-failsafe-plugin} for parallelization.
+ */
 public class VaadinServletTest {
 
+    @After
+    public void tearDown() {
+        Assert.assertNull(VaadinService.getCurrent());
+    }
+
     @Test
     public void testGetLastPathParameter() {
         assertEquals("",
@@ -111,13 +150,419 @@ public class VaadinServletTest {
 
     }
 
+    @Test
+    @SuppressWarnings("deprecation")
+    public void directoryIsNotResourceRequest() throws Exception {
+        VaadinServlet servlet = new VaadinServlet();
+        servlet.init(new MockServletConfig());
+        // this request isn't actually used for anything within the
+        // isAllowedVAADINResourceUrl calls, no need to configure it
+        HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
+
+        TemporaryFolder folder = TemporaryFolder.builder().build();
+        folder.create();
+
+        try {
+            File vaadinFolder = folder.newFolder("VAADIN");
+            vaadinFolder.createNewFile();
+
+            // generate URL so it is not ending with / so that we test the
+            // correct method
+            String rootAbsolutePath = folder.getRoot().getAbsolutePath()
+                    .replaceAll("\\\\", "/");
+            if (rootAbsolutePath.endsWith("/")) {
+                rootAbsolutePath = rootAbsolutePath.substring(0,
+                        rootAbsolutePath.length() - 1);
+            }
+            URL folderPath = new URL("file:///" + rootAbsolutePath);
+
+            assertFalse("Folder on disk should not be an allowed resource.",
+                    servlet.isAllowedVAADINResourceUrl(request, folderPath));
+
+            // Test any path ending with / to be seen as a directory
+            assertFalse(
+                    "Fake should not check the file system nor be an allowed resource.",
+                    servlet.isAllowedVAADINResourceUrl(request,
+                            new URL("file:///fake/")));
+
+            File archiveFile = createJAR(folder);
+            Path tempArchive = archiveFile.toPath();
+            String tempArchivePath = tempArchive.toString().replaceAll("\\\\",
+                    "/");
+
+            assertFalse(
+                    "Folder 'VAADIN' in jar should not be an allowed resource.",
+                    servlet.isAllowedVAADINResourceUrl(request, new URL(
+                            "jar:file:///" + tempArchivePath + "!/VAADIN")));
+
+            assertFalse(
+                    "File 'file.txt' inside jar should not be an allowed resource.",
+                    servlet.isAllowedVAADINResourceUrl(request, new URL(
+                            "jar:file:///" + tempArchivePath + "!/file.txt")));
+
+            assertTrue(
+                    "File 'file.txt' inside VAADIN folder within jar should be an allowed resource.",
+                    servlet.isAllowedVAADINResourceUrl(request,
+                            new URL("jar:file:///" + tempArchivePath
+                                    + "!/VAADIN/file.txt")));
+
+            assertFalse(
+                    "Directory 'folder' inside VAADIN folder within jar should not be an allowed resource.",
+                    servlet.isAllowedVAADINResourceUrl(request,
+                            new URL("jar:file:///" + tempArchivePath
+                                    + "!/VAADIN/folder")));
+
+            assertFalse(
+                    "File 'file.txt' outside of a jar should not be an allowed resource.",
+                    servlet.isAllowedVAADINResourceUrl(request, new URL(
+                            "file:///" + rootAbsolutePath + "/file.txt")));
+
+            assertTrue(
+                    "File 'file.txt' inside VAADIN folder outside of a jar should be an allowed resource.",
+                    servlet.isAllowedVAADINResourceUrl(request,
+                            new URL("file:///" + rootAbsolutePath
+                                    + "/VAADIN/file.txt")));
+
+        } finally {
+            folder.delete();
+        }
+    }
+
+    @Test
+    @SuppressWarnings("deprecation")
+    public void isAllowedVAADINResource_jarWarFileScheme_detectsAsStaticResources()
+            throws IOException, URISyntaxException, ServletException {
+        assertTrue("Can not run concurrently with other test",
+                VaadinServlet.openFileSystems.isEmpty());
+
+        VaadinServlet servlet = new VaadinServlet();
+        servlet.init(new MockServletConfig());
+        // this request isn't actually used for anything within the
+        // isAllowedVAADINResourceUrl calls, no need to configure it
+        HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
+
+        TemporaryFolder folder = TemporaryFolder.builder().build();
+        folder.create();
+
+        try {
+            File archiveFile = createJAR(folder);
+            File warFile = createWAR(folder, archiveFile);
+
+            // Instantiate URL stream handler factory to be able to handle war:
+            WarURLStreamHandlerFactory.getInstance();
+
+            URL folderResourceURL = new URL("jar:war:" + warFile.toURI().toURL()
+                    + "!/" + archiveFile.getName() + "!/VAADIN/folder");
+
+            Assert.assertTrue(
+                    "Should be evaluated as a static request because we cannot "
+                            + "determine non-file resources within jar files.",
+                    servlet.isAllowedVAADINResourceUrl(request,
+                            folderResourceURL));
+
+            URL fileResourceURL = new URL("jar:war:" + warFile.toURI().toURL()
+                    + "!/" + archiveFile.getName() + "!/VAADIN/file.txt");
+
+            Assert.assertTrue("Should be evaluated as a static request.",
+                    servlet.isAllowedVAADINResourceUrl(request,
+                            fileResourceURL));
+        } finally {
+            folder.delete();
+        }
+    }
+
+    @Test
+    @SuppressWarnings("deprecation")
+    public void isAllowedVAADINResource_jarInAJar_detectsAsStaticResources()
+            throws IOException, URISyntaxException, ServletException {
+        assertTrue("Can not run concurrently with other test",
+                VaadinServlet.openFileSystems.isEmpty());
+
+        VaadinServlet servlet = new VaadinServlet();
+        servlet.init(new MockServletConfig());
+        // this request isn't actually used for anything within the
+        // isAllowedVAADINResourceUrl calls, no need to configure it
+        HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
+
+        TemporaryFolder folder = TemporaryFolder.builder().build();
+        folder.create();
+
+        try {
+            File archiveFile = createJAR(folder);
+            File warFile = createWAR(folder, archiveFile);
+
+            URL folderResourceURL = new URL("jar:" + warFile.toURI().toURL()
+                    + "!/" + archiveFile.getName() + "!/VAADIN/folder");
+
+            Assert.assertTrue(
+                    "Should be evaluated as a static request because we cannot "
+                            + "determine non-file resources within jar files.",
+                    servlet.isAllowedVAADINResourceUrl(request,
+                            folderResourceURL));
+
+            URL fileResourceURL = new URL("jar:" + warFile.toURI().toURL()
+                    + "!/" + archiveFile.getName() + "!/VAADIN/file.txt");
+
+            Assert.assertTrue("Should be evaluated as a static request.",
+                    servlet.isAllowedVAADINResourceUrl(request,
+                            fileResourceURL));
+
+            URL fileNonStaticResourceURL = new URL(
+                    "jar:" + warFile.toURI().toURL() + "!/"
+                            + archiveFile.getName() + "!/file.txt");
+
+            Assert.assertFalse(
+                    "Should not be evaluated as a static request even within a "
+                            + "jar because it's not within 'VAADIN' folder.",
+                    servlet.isAllowedVAADINResourceUrl(request,
+                            fileNonStaticResourceURL));
+        } finally {
+            folder.delete();
+        }
+    }
+
+    @Test
+    public void openingJarFileSystemForDifferentFilesInSameJar_existingFileSystemIsUsed()
+            throws IOException, URISyntaxException, ServletException {
+        assertTrue("Can not run concurrently with other test",
+                VaadinServlet.openFileSystems.isEmpty());
+
+        VaadinServlet servlet = new VaadinServlet();
+        servlet.init(new MockServletConfig());
+
+        TemporaryFolder folder = TemporaryFolder.builder().build();
+        folder.create();
+
+        try {
+            File archiveFile = createJAR(folder);
+            String tempArchivePath = archiveFile.toPath().toString()
+                    .replaceAll("\\\\", "/");
+
+            URL folderResourceURL = new URL(
+                    "jar:file:///" + tempArchivePath + "!/VAADIN");
+
+            URL fileResourceURL = new URL(
+                    "jar:file:///" + tempArchivePath + "!/file.txt");
+
+            servlet.getFileSystem(folderResourceURL.toURI());
+            servlet.getFileSystem(fileResourceURL.toURI());
+
+            assertEquals("Same file should be marked for both resources",
+                    (Integer) 2, VaadinServlet.openFileSystems.entrySet()
+                            .iterator().next().getValue());
+            servlet.closeFileSystem(folderResourceURL.toURI());
+            assertEquals("Closing resource should be removed from jar uri",
+                    (Integer) 1, VaadinServlet.openFileSystems.entrySet()
+                            .iterator().next().getValue());
+            servlet.closeFileSystem(fileResourceURL.toURI());
+            assertTrue("Closing last resource should clear marking",
+                    VaadinServlet.openFileSystems.isEmpty());
+
+            try {
+                FileSystems.getFileSystem(folderResourceURL.toURI());
+                fail("Jar FileSystem should have been closed");
+            } catch (FileSystemNotFoundException fsnfe) {
+                // This should happen as we should not have an open FileSystem
+                // here.
+            }
+        } finally {
+            folder.delete();
+        }
+    }
+
+    @Test
+    public void concurrentRequestsToJarResources_checksAreCorrect()
+            throws IOException, InterruptedException, ExecutionException,
+            URISyntaxException, ServletException {
+        assertTrue("Can not run concurrently with other test",
+                VaadinServlet.openFileSystems.isEmpty());
+
+        VaadinServlet servlet = new VaadinServlet();
+        servlet.init(new MockServletConfig());
+        // this request isn't actually used for anything within the
+        // isAllowedVAADINResourceUrl calls, no need to configure it
+        HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
+
+        TemporaryFolder folder = TemporaryFolder.builder().build();
+        folder.create();
+
+        try {
+            File archiveFile = createJAR(folder);
+            String tempArchivePath = archiveFile.toPath().toString()
+                    .replaceAll("\\\\", "/");
+
+            URL fileNotResourceURL = new URL(
+                    "jar:file:///" + tempArchivePath + "!/file.txt");
+            String fileNotResourceErrorMessage = "File file.text outside "
+                    + "folder 'VAADIN' in jar should not be a static resource.";
+
+            checkAllowedVAADINResourceConcurrently(servlet, request,
+                    fileNotResourceURL, fileNotResourceErrorMessage, false);
+            ensureFileSystemsCleared(fileNotResourceURL);
+
+            URL folderNotResourceURL = new URL(
+                    "jar:file:///" + tempArchivePath + "!/VAADIN");
+            String folderNotResourceErrorMessage = "Folder 'VAADIN' in "
+                    + "jar should not be a static resource.";
+
+            checkAllowedVAADINResourceConcurrently(servlet, request,
+                    folderNotResourceURL, folderNotResourceErrorMessage, false);
+            ensureFileSystemsCleared(folderNotResourceURL);
+
+            URL fileIsResourceURL = new URL(
+                    "jar:file:///" + tempArchivePath + "!/VAADIN/file.txt");
+            String fileIsResourceErrorMessage = "File 'file.txt' inside "
+                    + "VAADIN folder within jar should be a static resource.";
+
+            checkAllowedVAADINResourceConcurrently(servlet, request,
+                    fileIsResourceURL, fileIsResourceErrorMessage, true);
+            ensureFileSystemsCleared(fileIsResourceURL);
+        } finally {
+            folder.delete();
+        }
+    }
+
     private HttpServletRequest createServletRequest(String servletPath,
             String pathInfo) {
         HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
         Mockito.when(request.getServletPath()).thenReturn(servletPath);
         Mockito.when(request.getPathInfo()).thenReturn(pathInfo);
-        Mockito.when(request.getRequestURI()).thenReturn("/context"+pathInfo);
+        Mockito.when(request.getRequestURI()).thenReturn("/context" + pathInfo);
         Mockito.when(request.getContextPath()).thenReturn("/context");
         return request;
     }
+
+    /**
+     * Creates an archive file {@code fake.jar} that contains two
+     * {@code file.txt} files, one of which resides inside {@code VAADIN}
+     * directory.
+     *
+     * @param folder
+     *            temporary folder that should house the archive file
+     * @return the archive file
+     * @throws IOException
+     */
+    private File createJAR(TemporaryFolder folder) throws IOException {
+        File archiveFile = new File(folder.getRoot(), "fake.jar");
+        archiveFile.createNewFile();
+        Path tempArchive = archiveFile.toPath();
+
+        try (ZipOutputStream zipOutputStream = new ZipOutputStream(
+                Files.newOutputStream(tempArchive))) {
+            // Create a file to the zip
+            zipOutputStream.putNextEntry(new ZipEntry("/file.txt"));
+            zipOutputStream.closeEntry();
+            // Create a directory to the zip
+            zipOutputStream.putNextEntry(new ZipEntry("VAADIN/"));
+            zipOutputStream.closeEntry();
+            // Create a file to the directory
+            zipOutputStream.putNextEntry(new ZipEntry("VAADIN/file.txt"));
+            zipOutputStream.closeEntry();
+            // Create another directory to the zip
+            zipOutputStream.putNextEntry(new ZipEntry("VAADIN/folder/"));
+            zipOutputStream.closeEntry();
+        }
+        return archiveFile;
+    }
+
+    private File createWAR(TemporaryFolder folder, File archiveFile)
+            throws IOException {
+        Path tempArchive = archiveFile.toPath();
+        File warFile = new File(folder.getRoot(), "fake.war");
+        warFile.createNewFile();
+        Path warArchive = warFile.toPath();
+
+        try (ZipOutputStream warOutputStream = new ZipOutputStream(
+                Files.newOutputStream(warArchive))) {
+            // Create a file to the zip
+            warOutputStream.putNextEntry(new ZipEntry(archiveFile.getName()));
+            warOutputStream.write(Files.readAllBytes(tempArchive));
+
+            warOutputStream.closeEntry();
+        }
+        return warFile;
+    }
+
+    /**
+     * Performs the resource URL validity check in five threads simultaneously,
+     * and ensures that the results match the given expected value.
+     *
+     * @param servlet
+     *            VaadinServlet instance
+     * @param request
+     *            HttpServletRequest instance (does not need to be properly
+     *            initialized)
+     * @param resourceURL
+     *            the resource URL to validate
+     * @param resourceErrorMessage
+     *            the error message if the validity check results don't match
+     *            the expected value
+     * @param expected
+     *            expected value from the validity check
+     *
+     * @throws InterruptedException
+     * @throws ExecutionException
+     */
+    @SuppressWarnings("deprecation")
+    private void checkAllowedVAADINResourceConcurrently(VaadinServlet servlet,
+            HttpServletRequest request, URL resourceURL,
+            String resourceErrorMessage, boolean expected)
+            throws InterruptedException, ExecutionException {
+        int THREADS = 5;
+
+        List<Callable<Result>> fileNotResource = IntStream.range(0, THREADS)
+                .mapToObj(i -> {
+                    Callable<Result> callable = () -> {
+                        try {
+                            if (expected != servlet.isAllowedVAADINResourceUrl(
+                                    request, resourceURL)) {
+                                throw new IllegalArgumentException(
+                                        resourceErrorMessage);
+                            }
+                        } catch (Exception e) {
+                            return new Result(e);
+                        }
+                        return new Result(null);
+                    };
+                    return callable;
+                }).collect(Collectors.toList());
+
+        ExecutorService executor = Executors.newFixedThreadPool(THREADS);
+        List<Future<Result>> futures = executor.invokeAll(fileNotResource);
+        List<String> exceptions = new ArrayList<>();
+
+        executor.shutdown();
+
+        for (Future<Result> resultFuture : futures) {
+            Result result = resultFuture.get();
+            if (result.exception != null) {
+                exceptions.add(result.exception.getMessage());
+            }
+        }
+
+        assertTrue("There were exceptions in concurrent calls {" + exceptions
+                + "}", exceptions.isEmpty());
+    }
+
+    private void ensureFileSystemsCleared(URL fileResourceURL)
+            throws URISyntaxException {
+        assertFalse("URI should have been cleared",
+                VaadinServlet.openFileSystems
+                        .containsKey(fileResourceURL.toURI()));
+        try {
+            FileSystems.getFileSystem(fileResourceURL.toURI());
+            fail("FileSystem for file resource should be closed");
+        } catch (FileSystemNotFoundException fsnfe) {
+            // This should happen as we should not have an open FileSystem
+            // here.
+        }
+    }
+
+    private static class Result {
+        final Exception exception;
+
+        Result(Exception exception) {
+            this.exception = exception;
+        }
+    }
 }
diff --git a/server/src/test/java/com/vaadin/server/WarURLStreamHandlerFactory.java b/server/src/test/java/com/vaadin/server/WarURLStreamHandlerFactory.java
new file mode 100644 (file)
index 0000000..5197c14
--- /dev/null
@@ -0,0 +1,196 @@
+package com.vaadin.server;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Serializable;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLConnection;
+import java.net.URLStreamHandler;
+import java.net.URLStreamHandlerFactory;
+import java.security.Permission;
+
+/**
+ * Test factory for URL stream protocol handlers, needed for WAR handling in
+ * {@link VaadinServletTest}. Cherry-picked from Flow, some of the
+ * implementation details are not needed for Vaadin 8 at the moment, but they
+ * are left in because they aren't interfering either.
+ */
+public class WarURLStreamHandlerFactory
+        implements URLStreamHandlerFactory, Serializable {
+
+    private static final String WAR_PROTOCOL = "war";
+
+    // Singleton instance
+    private static volatile WarURLStreamHandlerFactory instance = null;
+
+    private final boolean registered;
+
+    /**
+     * Obtain a reference to the singleton instance. It is recommended that
+     * callers check the value of {@link #isRegistered()} before using the
+     * returned instance.
+     *
+     * @return A reference to the singleton instance
+     */
+    public static WarURLStreamHandlerFactory getInstance() {
+        getInstanceInternal(true);
+        return instance;
+    }
+
+    private static WarURLStreamHandlerFactory getInstanceInternal(
+            boolean register) {
+        // Double checked locking. OK because instance is volatile.
+        if (instance == null) {
+            synchronized (WarURLStreamHandlerFactory.class) {
+                if (instance == null) {
+                    instance = new WarURLStreamHandlerFactory(register);
+                }
+            }
+        }
+        return instance;
+    }
+
+    private WarURLStreamHandlerFactory(boolean register) {
+        // Hide default constructor
+        // Singleton pattern to ensure there is only one instance of this
+        // factory
+        registered = register;
+        if (register) {
+            URL.setURLStreamHandlerFactory(this);
+        }
+    }
+
+    public boolean isRegistered() {
+        return registered;
+    }
+
+    /**
+     * Register this factory with the JVM. May be called more than once. The
+     * implementation ensures that registration only occurs once.
+     *
+     * @return <code>true</code> if the factory is already registered with the
+     *         JVM or was successfully registered as a result of this call.
+     *         <code>false</code> if the factory was disabled prior to this
+     *         call.
+     */
+    public static boolean register() {
+        return getInstanceInternal(true).isRegistered();
+    }
+
+    /**
+     * Prevent this this factory from registering with the JVM. May be called
+     * more than once.
+     *
+     * @return <code>true</code> if the factory is already disabled or was
+     *         successfully disabled as a result of this call.
+     *         <code>false</code> if the factory was already registered prior to
+     *         this call.
+     */
+    public static boolean disable() {
+        return !getInstanceInternal(false).isRegistered();
+    }
+
+    @Override
+    public URLStreamHandler createURLStreamHandler(String protocol) {
+
+        // Tomcat's handler always takes priority so applications can't override
+        // it.
+        if (WAR_PROTOCOL.equals(protocol)) {
+            return new WarHandler();
+        }
+
+        // Unknown protocol
+        return null;
+    }
+
+    public static class WarHandler extends URLStreamHandler
+            implements Serializable {
+
+        @Override
+        protected URLConnection openConnection(URL u) throws IOException {
+            return new WarURLConnection(u);
+        }
+
+        @Override
+        protected void setURL(URL u, String protocol, String host, int port,
+                String authority, String userInfo, String path, String query,
+                String ref) {
+            if (path.startsWith("file:") && !path.startsWith("file:/")) {
+                /*
+                 * Work around a problem with the URLs in the security policy
+                 * file. On Windows, the use of ${catalina.[home|base]} in the
+                 * policy file results in codebase URLs of the form file:C:/...
+                 * when they should be file:/C:/...
+                 *
+                 * For file: and jar: URLs, the JRE compensates for this. It
+                 * does not compensate for this for war:file:... URLs.
+                 * Therefore, we do that here
+                 */
+                path = "file:/" + path.substring(5);
+            }
+            super.setURL(u, protocol, host, port, authority, userInfo, path,
+                    query, ref);
+        }
+
+    }
+
+    public static class WarURLConnection extends URLConnection
+            implements Serializable {
+
+        private final URLConnection wrappedJarUrlConnection;
+        private boolean connected;
+
+        protected WarURLConnection(URL url) throws IOException {
+            super(url);
+            URL innerJarUrl = warToJar(url);
+            wrappedJarUrlConnection = innerJarUrl.openConnection();
+        }
+
+        @Override
+        public void connect() throws IOException {
+            if (!connected) {
+                wrappedJarUrlConnection.connect();
+                connected = true;
+            }
+        }
+
+        @Override
+        public InputStream getInputStream() throws IOException {
+            connect();
+            return wrappedJarUrlConnection.getInputStream();
+        }
+
+        @Override
+        public Permission getPermission() throws IOException {
+            return wrappedJarUrlConnection.getPermission();
+        }
+
+        @Override
+        public long getLastModified() {
+            return wrappedJarUrlConnection.getLastModified();
+        }
+
+        @Override
+        public int getContentLength() {
+            return wrappedJarUrlConnection.getContentLength();
+        }
+
+        @Override
+        public long getContentLengthLong() {
+            return wrappedJarUrlConnection.getContentLengthLong();
+        }
+
+        public static URL warToJar(URL warUrl) throws MalformedURLException {
+            // Assumes that the spec is absolute and starts war:file:/...
+            String file = warUrl.getFile();
+            if (file.contains("*/")) {
+                file = file.replaceFirst("\\*/", "!/");
+            } else if (file.contains("^/")) {
+                file = file.replaceFirst("\\^/", "!/");
+            }
+
+            return new URL("jar", warUrl.getHost(), warUrl.getPort(), file);
+        }
+    }
+}
\ No newline at end of file