diff --git a/dotCMS/src/main/java/com/dotmarketing/webdav/BasicFolderResourceImpl.java b/dotCMS/src/main/java/com/dotmarketing/webdav/BasicFolderResourceImpl.java index fc0dc4ebd263..4f5912d7e12d 100644 --- a/dotCMS/src/main/java/com/dotmarketing/webdav/BasicFolderResourceImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/webdav/BasicFolderResourceImpl.java @@ -49,7 +49,21 @@ public BasicFolderResourceImpl(String path) { this.isAutoPub=dotDavHelper.isAutoPub(path); } - public Resource createNew(String newName, final InputStream in, final Long length, final String contentType) throws IOException, DotRuntimeException { + /** + * Creates a file named {@code newName} in this folder. A temp-style name goes to temp storage + * and must be a single plain path segment; any other name is stored as content. + * + * @param newName the file name received from the WebDAV client + * @param in the file content + * @param length the content length, if known + * @param contentType the content type, if known + * @return the created resource + * @throws IOException if the content cannot be read or written + * @throws DotRuntimeException if the file cannot be created + * @throws BadRequestException (HTTP 400) when a temp-style name is not a single plain path segment + */ + public Resource createNew(String newName, final InputStream in, final Long length, final String contentType) + throws IOException, DotRuntimeException, BadRequestException { if(newName.matches("^\\.(.*)-Spotlight$")){ // http://jira.dotmarketing.net/browse/DOTCMS-7285 @@ -83,6 +97,7 @@ public Resource createNew(String newName, final InputStream in, final Long lengt } } else { + DotWebdavHelper.requirePlainSegment(this, newName); return this.createNewTemporalResource(newName, in); } } // createNew. diff --git a/dotCMS/src/main/java/com/dotmarketing/webdav/DotWebdavHelper.java b/dotCMS/src/main/java/com/dotmarketing/webdav/DotWebdavHelper.java index c08834e0cb35..43327b506d36 100644 --- a/dotCMS/src/main/java/com/dotmarketing/webdav/DotWebdavHelper.java +++ b/dotCMS/src/main/java/com/dotmarketing/webdav/DotWebdavHelper.java @@ -15,6 +15,7 @@ import com.bradmcevoy.http.Request; import com.bradmcevoy.http.Request.Method; import com.bradmcevoy.http.Resource; +import com.bradmcevoy.http.exceptions.BadRequestException; import com.dotmarketing.beans.Host; import com.dotmarketing.beans.Identifier; import com.dotmarketing.beans.Permission; @@ -73,6 +74,7 @@ import java.util.List; import java.util.Optional; import java.util.Timer; +import java.util.function.Supplier; import org.apache.commons.lang.StringUtils; import org.apache.oro.text.regex.MalformedPatternException; import org.apache.oro.text.regex.Perl5Compiler; @@ -101,7 +103,8 @@ protected Perl5Matcher initialValue() { } }; private org.apache.oro.text.regex.Pattern tempResourcePattern; - private String tempFolderPath = "dotwebdav"; + /** Sub-folder of the asset temp path that holds WebDAV temp storage, apart from other features. */ + private static final String TEMP_FOLDER_NAME = "dotwebdav"; private HostAPI hostAPI = APILocator.getHostAPI(); private FolderAPI folderAPI = APILocator.getFolderAPI(); @@ -391,14 +394,26 @@ public Folder loadFolder(String url,User user) throws IOException{ return folder; } + /** + * Resolves a temporary WebDAV resource from a request-derived path. The path is built under + * {@link #getTempDir()} and must resolve inside it; otherwise {@code null} is returned and the + * caller treats it as "not found". + * + * @param url the request URL (endpoint mapping is stripped internally) + * @return the temp {@link File}, or {@code null} when the path has a {@code .} or {@code ..} + * segment or is not inside the temp directory + */ public File loadTempFile(String url){ try { url = stripMapping(url); } catch (IOException e) { - Logger.error( this, "Error happened with uri: [" + url + "]", e); + Logger.error(this, "Unable to strip the WebDAV mapping from a temp path", e); + } + try { + return resolveTempPath(url, () -> new IOException("Invalid WebDAV temp path")); + } catch (IOException e) { + return null; } - Logger.debug(this, "Getting temp file from path " + url); - return new File(getTempDir().getPath() + url); } /** @@ -503,8 +518,165 @@ public List getChildrenOfFolder ( Folder parentFolder, User user, bool return result; } + /** + * Returns the directory that holds WebDAV temp storage: a dedicated sub-folder of the asset temp + * path, so WebDAV never shares a root with the other features that write there. + * + * @return the WebDAV temp directory + */ public File getTempDir () { - return new File(ConfigUtils.getAssetTempPath()); + return new File(ConfigUtils.getAssetTempPath(), TEMP_FOLDER_NAME); + } + + /** + * Verifies that {@code candidate} resolves to a location strictly inside the WebDAV temp + * directory ({@link #getTempDir()}). The temp directory itself is not a valid temp resource. + * Canonicalization collapses {@code .} and {@code ..} segments and resolves symlinks, so the + * check holds however the path is written. Fails closed: any canonicalization error is treated + * as "not contained". + * + * @param candidate the {@link File} built from request-derived input + * @return {@code true} only when the canonical candidate is a descendant of the temp directory + */ + private boolean isWithinTempDir(final File candidate) { + try { + final String root = getTempDir().getCanonicalPath(); + return candidate.getCanonicalPath().startsWith(root + File.separator); + } catch (IOException e) { + Logger.error(this, "WebDAV temp-path canonicalization failed; rejecting", e); + logRejection("Rejected a WebDAV temp path that could not be canonicalized"); + return false; + } + } + + /** + * Tells whether a path has a {@code .} or {@code ..} segment. Such segments are never needed to + * address temp storage, so a path that has one is rejected before it is resolved. + * + * @param path the request-derived path, using {@code /} or the platform separator + * @return {@code true} when any segment is exactly {@code .} or {@code ..} + */ + static boolean hasDotSegment(final String path) { + for (final String segment : path.split("[/\\\\]")) { + if (".".equals(segment) || "..".equals(segment)) { + return true; + } + } + return false; + } + + /** + * Resolves a request-derived path under the temp directory. The path must have no {@code .} or + * {@code ..} segment and must resolve strictly inside the temp directory; otherwise the rejection + * is logged and the exception built by {@code rejection} is thrown. + * + * @param relativePath the path to append to the temp directory + * @param rejection builds the exception thrown when the path is not valid + * @param the type of the rejection exception + * @return the resolved {@link File} + * @throws E when the path is not valid + */ + private File resolveTempPath(final String relativePath, final Supplier rejection) + throws E { + if (hasDotSegment(relativePath)) { + logRejection("Rejected a WebDAV temp path with a dot segment"); + throw rejection.get(); + } + return requireWithinTempDir(new File(getTempDir().getPath() + relativePath), rejection); + } + + /** + * Logs a rejected WebDAV temp-storage request to the security log, with the id of the user who + * made it. The message never includes request data. + * + * @param message what was rejected + */ + private static void logRejection(final String message) { + SecurityLogger.logWarn(DotWebdavHelper.class, () -> message + ", userId: " + currentUserId()); + } + + /** + * Returns the id of the user authenticated on the current WebDAV request, or {@code unknown} when + * the code does not run inside a WebDAV request. + * + * @return the current user id, or {@code unknown} + */ + private static String currentUserId() { + final Request request = HttpManager.request(); + final Object tag = request != null && request.getAuthorization() != null + ? request.getAuthorization().getTag() : null; + return tag instanceof User ? ((User) tag).getUserId() : "unknown"; + } + + /** + * Returns {@code candidate} when it resolves strictly inside the temp directory (see + * {@link #isWithinTempDir(File)}); otherwise logs the rejection and throws the exception built by + * {@code rejection}. Each caller supplies the exception that matches its own failure contract. + * + * @param candidate the {@link File} built from request-derived input + * @param rejection builds the exception thrown when the candidate is outside the temp directory + * @param the type of the rejection exception + * @return {@code candidate}, unchanged + * @throws E when the candidate is not inside the temp directory + */ + private File requireWithinTempDir(final File candidate, final Supplier rejection) + throws E { + if (!isWithinTempDir(candidate)) { + logRejection("Rejected a WebDAV temp path outside the temp directory"); + throw rejection.get(); + } + return candidate; + } + + /** + * Tells whether a client-supplied name can be used as a child name in temp storage: a single + * plain path segment that is not empty, not {@code .} or {@code ..}, and contains no path + * separator or NUL character. + * + * @param name the name received from the WebDAV client + * @return {@code true} when the name is a single plain path segment + */ + static boolean isPlainSegment(final String name) { + return name != null + && !name.isEmpty() + && !".".equals(name) + && !"..".equals(name) + && name.indexOf('/') < 0 + && name.indexOf('\\') < 0 + && name.indexOf('\0') < 0; + } + + /** + * Rejects a client-supplied name that is not a single plain path segment, before it reaches + * temp storage. + * + * @param resource the resource handling the request, reported in the rejection + * @param name the name received from the WebDAV client + * @throws BadRequestException (HTTP 400) when the name is not a single plain path segment + */ + static void requirePlainSegment(final Resource resource, final String name) throws BadRequestException { + if (!isPlainSegment(name)) { + logRejection("Rejected a WebDAV name that is not a single path segment"); + throw new BadRequestException(resource, "Invalid name"); + } + } + + /** + * Resolves the target of a write into temp storage: {@code name} inside {@code folder}. The name + * must be a single plain path segment and the target must stay strictly inside the temp + * directory. + * + * @param resource the resource handling the request, reported in the rejection + * @param folder the temp folder receiving the write + * @param name the name received from the WebDAV client + * @return the target {@link File} + * @throws BadRequestException (HTTP 400) when the name or the target is not valid + */ + File resolveTempChild(final Resource resource, final File folder, final String name) + throws BadRequestException { + requirePlainSegment(resource, name); + return requireWithinTempDir(new File(folder, name), + () -> new BadRequestException(resource, "Invalid destination")); } public String getHostName ( String uri ) { @@ -523,6 +695,14 @@ public boolean isTempResource(String path){ return false; } + /** + * Creates a temporary WebDAV folder under {@link #getTempDir()}. The path must have no {@code .} or + * {@code ..} segment and resolve inside the temp directory; otherwise nothing is created. + * + * @param path the request-derived folder path (endpoint mapping is stripped internally) + * @return the created temp folder + * @throws DotRuntimeException if the path has a dot segment or is not inside the temp directory + */ public File createTempFolder(String path){ try { path = stripMapping(path); @@ -535,7 +715,8 @@ public File createTempFolder(String path){ path = path.substring(1, path.length()); } path = path.replace("/", File.separator); - File f = new File(getTempDir().getPath() + File.separator + path); + final File f = resolveTempPath(File.separator + path, + () -> new DotRuntimeException("Invalid WebDAV temp folder path")); f.mkdirs(); return f; } @@ -575,8 +756,17 @@ public File copyFileToTemp(IFileAsset file, File tempFolder) throws IOException{ return nf; } + /** + * Creates a temporary WebDAV file under {@link #getTempDir()}. The path must have no {@code .} or + * {@code ..} segment and resolve inside the temp directory; otherwise nothing is created. + * + * @param path the request-derived file path, relative to the temp directory + * @return the created temp file + * @throws IOException if the path has a dot segment or is not inside the temp directory, or the + * file cannot be created + */ public File createTempFile(String path) throws IOException{ - File file = new File(getTempDir().getPath() + path); + final File file = resolveTempPath(path, () -> new IOException("Invalid WebDAV temp file path")); String p = file.getPath().substring(0,file.getPath().lastIndexOf(File.separator)); File f = new File(p); f.mkdirs(); diff --git a/dotCMS/src/main/java/com/dotmarketing/webdav/FolderResourceImpl.java b/dotCMS/src/main/java/com/dotmarketing/webdav/FolderResourceImpl.java index c9beeb8b8969..7670bc2fdc09 100644 --- a/dotCMS/src/main/java/com/dotmarketing/webdav/FolderResourceImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/webdav/FolderResourceImpl.java @@ -55,14 +55,21 @@ public FolderResourceImpl(Folder folder, String path) { this.hostAPI = APILocator.getHostAPI(); } - /* (non-Javadoc) - * @see com.bradmcevoy.http.MakeCollectionableResource#createCollection(java.lang.String) + /** + * Creates a folder named {@code newName} inside this folder. A temp-style name goes to temp + * storage and must be a single plain path segment. + * + * @param newName the folder name received from the WebDAV client + * @return the created folder + * @throws DotRuntimeException if the folder cannot be created + * @throws BadRequestException (HTTP 400) when a temp-style name is not a single plain path segment */ - public CollectionResource createCollection(String newName) throws DotRuntimeException { + public CollectionResource createCollection(String newName) throws DotRuntimeException, BadRequestException { User user=(User)HttpManager.request().getAuthorization().getTag(); String folderPath =""; if(dotDavHelper.isTempResource(newName)){ + DotWebdavHelper.requirePlainSegment(this, newName); Host host; try { host = hostAPI.find(folder.getHostId(), user, false); @@ -78,10 +85,8 @@ public CollectionResource createCollection(String newName) throws DotRuntimeExce final String hostFolderPath = new StringBuilder(File.separator).append(host.getHostname()) .append(!folderPath.endsWith(File.separator)?folderPath + File.separator : folderPath).toString(); - dotDavHelper.createTempFolder(hostFolderPath + newName); - File file = new File(File.separator + host.getHostname() + folderPath); - TempFolderResourceImpl tempFolderResource = new TempFolderResourceImpl(file.getPath(),file ,isAutoPub); - return tempFolderResource; + final File created = dotDavHelper.createTempFolder(hostFolderPath + newName); + return new TempFolderResourceImpl(created.getPath(), created, isAutoPub); } if(!path.endsWith("/")){ path = path + "/"; @@ -259,6 +264,7 @@ public void copyTo(CollectionResource collRes, String name) throws NotAuthorized User user=(User)HttpManager.request().getAuthorization().getTag(); if(collRes instanceof TempFolderResourceImpl){ + DotWebdavHelper.requirePlainSegment(this, name); TempFolderResourceImpl tr = (TempFolderResourceImpl)collRes; try { dotDavHelper.copyFolderToTemp(folder, tr.getFolder(), user, name, isAutoPub, lang); @@ -289,13 +295,20 @@ public void copyTo(CollectionResource collRes, String name) throws NotAuthorized } } - /* (non-Javadoc) - * @see com.bradmcevoy.http.MoveableResource#moveTo(com.bradmcevoy.http.CollectionResource, java.lang.String) + /** + * Moves this folder into {@code collRes} under {@code name}. A move that ends in temp storage + * requires {@code name} to be a single plain path segment. + * + * @param collRes the destination collection + * @param name the destination name received from the WebDAV client + * @throws DotRuntimeException if the move fails + * @throws BadRequestException (HTTP 400) when a temp destination name is not a single plain path segment */ - public void moveTo(CollectionResource collRes, String name) throws DotRuntimeException{ + public void moveTo(CollectionResource collRes, String name) throws DotRuntimeException, BadRequestException { User user=(User)HttpManager.request().getAuthorization().getTag(); if(collRes instanceof TempFolderResourceImpl){ Logger.debug(this, "Webdav clients wants to move a file from dotcms to a temporary storage but we don't allow this in fear that the transaction may break and delete a file from dotcms"); + DotWebdavHelper.requirePlainSegment(this, name); TempFolderResourceImpl tr = (TempFolderResourceImpl)collRes; try { dotDavHelper.copyFolderToTemp(folder, tr.getFolder(), user, name, isAutoPub, lang); @@ -306,6 +319,7 @@ public void moveTo(CollectionResource collRes, String name) throws DotRuntimeExc }else if(collRes instanceof FolderResourceImpl){ FolderResourceImpl fr = (FolderResourceImpl)collRes; if(dotDavHelper.isTempResource(name)){ + DotWebdavHelper.requirePlainSegment(this, name); Host host; String folderPath = ""; try { @@ -401,9 +415,23 @@ public Long getMaxAgeSeconds(Auth arg0) { } + /** + * Creates a folder named {@code name} and locks it. The lock interface only allows + * {@link NotAuthorizedException}, so an invalid temp-style name is reported as a runtime error. + * + * @param name the folder name received from the WebDAV client + * @param timeout the requested lock timeout + * @param lockInfo the requested lock details + * @return the lock token of the created folder + * @throws NotAuthorizedException if the lock is not authorized + */ public LockToken createAndLock(String name, LockTimeout timeout, LockInfo lockInfo) throws NotAuthorizedException { - createCollection(name); + try { + createCollection(name); + } catch (BadRequestException e) { + throw new DotRuntimeException(e.getReason(), e); + } return lock(timeout, lockInfo).getLockToken(); } diff --git a/dotCMS/src/main/java/com/dotmarketing/webdav/HostResourceImpl.java b/dotCMS/src/main/java/com/dotmarketing/webdav/HostResourceImpl.java index 72f7cc78b99e..63832bdd8cf4 100644 --- a/dotCMS/src/main/java/com/dotmarketing/webdav/HostResourceImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/webdav/HostResourceImpl.java @@ -252,9 +252,19 @@ public Date getCreateDate() { return host.getModDate(); } - public CollectionResource createCollection(String newName) throws DotRuntimeException { + /** + * Creates a folder named {@code newName} at the root of this site. A temp-style name goes to + * temp storage and must be a single plain path segment. + * + * @param newName the folder name received from the WebDAV client + * @return the created folder + * @throws DotRuntimeException if the folder cannot be created + * @throws BadRequestException (HTTP 400) when a temp-style name is not a single plain path segment + */ + public CollectionResource createCollection(String newName) throws DotRuntimeException, BadRequestException { User user=(User)HttpManager.request().getAuthorization().getTag(); if(dotDavHelper.isTempResource(newName)){ + DotWebdavHelper.requirePlainSegment(this, newName); File f = dotDavHelper.createTempFolder(File.separator + host.getHostname() + File.separator + newName); TempFolderResourceImpl tr = new TempFolderResourceImpl(f.getPath(),f ,isAutoPub); return tr; @@ -275,9 +285,23 @@ public CollectionResource createCollection(String newName) throws DotRuntimeExce } } + /** + * Creates a folder named {@code name} and locks it. The lock interface only allows + * {@link NotAuthorizedException}, so an invalid temp-style name is reported as a runtime error. + * + * @param name the folder name received from the WebDAV client + * @param timeout the requested lock timeout + * @param lockInfo the requested lock details + * @return the lock token of the created folder + * @throws NotAuthorizedException if the lock is not authorized + */ public LockToken createAndLock(String name, LockTimeout timeout, LockInfo lockInfo) throws NotAuthorizedException { - createCollection(name); + try { + createCollection(name); + } catch (BadRequestException e) { + throw new DotRuntimeException(e.getReason(), e); + } return lock(timeout, lockInfo).getLockToken(); } diff --git a/dotCMS/src/main/java/com/dotmarketing/webdav/LanguageFolderResourceImpl.java b/dotCMS/src/main/java/com/dotmarketing/webdav/LanguageFolderResourceImpl.java index b580ba1f8cb7..1b6d29edf6c2 100644 --- a/dotCMS/src/main/java/com/dotmarketing/webdav/LanguageFolderResourceImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/webdav/LanguageFolderResourceImpl.java @@ -15,7 +15,9 @@ import com.bradmcevoy.http.Request; import com.bradmcevoy.http.Request.Method; import com.bradmcevoy.http.Resource; +import com.bradmcevoy.http.exceptions.BadRequestException; import com.bradmcevoy.http.exceptions.NotAuthorizedException; +import com.dotmarketing.exception.DotRuntimeException; import com.dotmarketing.business.Role; import com.dotmarketing.business.web.WebAPILocator; import com.dotmarketing.util.Config; @@ -81,15 +83,20 @@ public LanguageFolderResourceImpl(String path) { } } - /* (non-Javadoc) - * @see com.bradmcevoy.http.MakeCollectionableResource#createCollection(java.lang.String) + /** + * Creates a temp folder for a temp-style {@code newName} at the language root; any other name is + * not supported here. The temp-style name must be a single plain path segment. + * + * @param newName the folder name received from the WebDAV client + * @return the created temp folder, or {@code null} when the name is not supported here + * @throws BadRequestException (HTTP 400) when a temp-style name is not a single plain path segment */ - public CollectionResource createCollection(String newName) { + public CollectionResource createCollection(String newName) throws BadRequestException { if(dotDavHelper.isTempResource(newName) && isLanguageRoot){ - dotDavHelper.createTempFolder(File.separator + "system" + File.separator + "languages" + File.separator + newName); - File f = new File(File.separator + "system" + File.separator + "languages"); - TempFolderResourceImpl tr = new TempFolderResourceImpl(f.getPath(),f ,true); - return tr; + DotWebdavHelper.requirePlainSegment(this, newName); + final File created = dotDavHelper.createTempFolder( + File.separator + "system" + File.separator + "languages" + File.separator + newName); + return new TempFolderResourceImpl(created.getPath(), created, true); }else{ return null; } @@ -374,9 +381,23 @@ public Long getMaxAgeSeconds(Auth arg0) { return (long)60; } + /** + * Creates a temp folder named {@code name} and locks it. The lock interface only allows + * {@link NotAuthorizedException}, so an invalid temp-style name is reported as a runtime error. + * + * @param name the folder name received from the WebDAV client + * @param timeout the requested lock timeout + * @param lockInfo the requested lock details + * @return the lock token of the created folder + * @throws NotAuthorizedException if the lock is not authorized + */ public LockToken createAndLock(String name, LockTimeout timeout, LockInfo lockInfo) throws NotAuthorizedException { - createCollection(name); + try { + createCollection(name); + } catch (BadRequestException e) { + throw new DotRuntimeException(e.getReason(), e); + } return lock(timeout, lockInfo).getLockToken(); } diff --git a/dotCMS/src/main/java/com/dotmarketing/webdav/TempFileResourceImpl.java b/dotCMS/src/main/java/com/dotmarketing/webdav/TempFileResourceImpl.java index 9d3224f1d94a..973ffdeb803b 100644 --- a/dotCMS/src/main/java/com/dotmarketing/webdav/TempFileResourceImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/webdav/TempFileResourceImpl.java @@ -13,6 +13,7 @@ import com.bradmcevoy.http.Range; import com.bradmcevoy.http.Request; import com.bradmcevoy.http.Resource; +import com.bradmcevoy.http.exceptions.BadRequestException; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.PermissionAPI; import com.dotmarketing.util.Config; @@ -147,12 +148,22 @@ public Long getMaxAgeSeconds() { } - public void copyTo(CollectionResource collRes, String name) { + /** + * Copies this temp file into {@code collRes} under {@code name}. The name must be a single plain + * path segment, and a copy into temp storage must stay inside the temp directory. + * + * @param collRes the destination collection + * @param name the destination name received from the WebDAV client + * @throws BadRequestException (HTTP 400) when the name or the temp target is not valid + */ + public void copyTo(CollectionResource collRes, String name) throws BadRequestException { + DotWebdavHelper.requirePlainSegment(this, name); User user=(User)HttpManager.request().getAuthorization().getTag(); if(collRes instanceof TempFolderResourceImpl){ TempFolderResourceImpl tr = (TempFolderResourceImpl)collRes; + final File target = dotDavHelper.resolveTempChild(this, tr.getFolder(), name); try { - FileUtil.copyFile(file, new File(tr.getFolder().getPath() + File.separator + name)); + FileUtil.copyFile(file, target); } catch (Exception e) { Logger.error(this, e.getMessage(), e); return; @@ -191,12 +202,22 @@ public void delete() { } - public void moveTo(CollectionResource collRes, String name) { + /** + * Moves this temp file into {@code collRes} under {@code name}. The name must be a single plain + * path segment, and a move into temp storage must stay inside the temp directory. + * + * @param collRes the destination collection + * @param name the destination name received from the WebDAV client + * @throws BadRequestException (HTTP 400) when the name or the temp target is not valid + */ + public void moveTo(CollectionResource collRes, String name) throws BadRequestException { + DotWebdavHelper.requirePlainSegment(this, name); User user=(User)HttpManager.request().getAuthorization().getTag(); if(collRes instanceof TempFolderResourceImpl){ TempFolderResourceImpl tr = (TempFolderResourceImpl)collRes; + final File target = dotDavHelper.resolveTempChild(this, tr.getFolder(), name); try { - FileUtil.copyFile(file, new File(tr.getFolder().getPath() + File.separator + name)); + FileUtil.copyFile(file, target); file.delete(); } catch (Exception e) { Logger.error(this, e.getMessage(), e); diff --git a/dotCMS/src/main/java/com/dotmarketing/webdav/TempFolderResourceImpl.java b/dotCMS/src/main/java/com/dotmarketing/webdav/TempFolderResourceImpl.java index a6ce67a1c76a..e76eeb3a0d54 100644 --- a/dotCMS/src/main/java/com/dotmarketing/webdav/TempFolderResourceImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/webdav/TempFolderResourceImpl.java @@ -17,7 +17,9 @@ import com.bradmcevoy.http.Request; import com.bradmcevoy.http.Request.Method; import com.bradmcevoy.http.Resource; +import com.bradmcevoy.http.exceptions.BadRequestException; import com.bradmcevoy.http.exceptions.NotAuthorizedException; +import com.dotmarketing.exception.DotRuntimeException; import com.dotmarketing.util.Logger; import com.liferay.portal.model.User; import com.liferay.util.FileUtil; @@ -50,14 +52,18 @@ public TempFolderResourceImpl(String path, File folder, boolean isAutoPub) { this.folder = folder; } - /* (non-Javadoc) - * @see com.bradmcevoy.http.MakeCollectionableResource#createCollection(java.lang.String) + /** + * Creates a sub-folder named {@code newName} inside this temp folder. The name must be a single + * plain path segment and the new folder must stay inside the temp directory. + * + * @param newName the folder name received from the WebDAV client + * @return the created temp folder + * @throws BadRequestException (HTTP 400) when the name or the target is not valid */ - public CollectionResource createCollection(String newName) { - dotDavHelper.createTempFolder(folder.getPath() + File.separator + newName); - File f = new File(folder.getPath() + File.separator + newName); - TempFolderResourceImpl tr = new TempFolderResourceImpl(folder.getPath() + File.separator + newName,f, isAutoPub); - return tr; + public CollectionResource createCollection(String newName) throws BadRequestException { + final File f = dotDavHelper.resolveTempChild(this, folder, newName); + dotDavHelper.createTempFolder(f.getPath()); + return new TempFolderResourceImpl(f.getPath(), f, isAutoPub); } /* (non-Javadoc) @@ -164,11 +170,21 @@ public String getUniqueId() { return folder.hashCode() + ""; } - /* (non-Javadoc) - * @see com.bradmcevoy.http.PutableResource#createNew(java.lang.String, java.io.InputStream, java.lang.Long, java.lang.String) + /** + * Writes a new temp file named {@code newName} inside this temp folder. The name must be a single + * plain path segment and the file must stay inside the temp directory. + * + * @param newName the file name received from the WebDAV client + * @param in the file content + * @param length the content length, if known + * @param contentType the content type, if known + * @return the written temp file + * @throws IOException if the file cannot be written + * @throws BadRequestException (HTTP 400) when the name or the target is not valid */ - public Resource createNew(String newName, InputStream in, Long length, String contentType) throws IOException { - File f = new File(folder.getPath() + File.separator + newName); + public Resource createNew(String newName, InputStream in, Long length, String contentType) + throws IOException, BadRequestException { + File f = dotDavHelper.resolveTempChild(this, folder, newName); if(!f.exists()){ String p = f.getPath().substring(0,f.getPath().lastIndexOf(File.separator)); File fe = new File(p); @@ -186,16 +202,21 @@ public Resource createNew(String newName, InputStream in, Long length, String co return tr; } - /* (non-Javadoc) - * @see com.bradmcevoy.http.CopyableResource#copyTo(com.bradmcevoy.http.CollectionResource, java.lang.String) + /** + * Copies this temp folder into {@code collRes} under {@code name}. The name must be a single plain + * path segment, and a copy into temp storage must stay inside the temp directory. + * + * @param collRes the destination collection + * @param name the destination name received from the WebDAV client + * @throws BadRequestException (HTTP 400) when the name or the temp target is not valid */ - public void copyTo(CollectionResource collRes, String name) { + public void copyTo(CollectionResource collRes, String name) throws BadRequestException { + DotWebdavHelper.requirePlainSegment(this, name); User user=(User)HttpManager.request().getAuthorization().getTag(); if(collRes instanceof TempFolderResourceImpl){ TempFolderResourceImpl tr = (TempFolderResourceImpl)collRes; + final File dest = dotDavHelper.resolveTempChild(this, tr.getFolder(), name); try { - String p = tr.getFolder().getPath(); - File dest = new File(tr.getFolder().getPath() + File.separator + name); FileUtil.copyDirectory(folder, dest); } catch (Exception e) { Logger.error(this, e.getMessage(), e); @@ -237,15 +258,21 @@ public void sendContent(OutputStream arg0, Range arg1, Map arg2, return; } - /* (non-Javadoc) - * @see com.bradmcevoy.http.MoveableResource#moveTo(com.bradmcevoy.http.CollectionResource, java.lang.String) + /** + * Moves this temp folder into {@code collRes} under {@code name}. The name must be a single plain + * path segment, and a move into temp storage must stay inside the temp directory. + * + * @param collRes the destination collection + * @param name the destination name received from the WebDAV client + * @throws BadRequestException (HTTP 400) when the name or the temp target is not valid */ - public void moveTo(CollectionResource collRes, String name) { + public void moveTo(CollectionResource collRes, String name) throws BadRequestException { + DotWebdavHelper.requirePlainSegment(this, name); User user=(User)HttpManager.request().getAuthorization().getTag(); if(collRes instanceof TempFolderResourceImpl){ TempFolderResourceImpl tr = (TempFolderResourceImpl)collRes; + final File dest = dotDavHelper.resolveTempChild(this, tr.getFolder(), name); try { - File dest = new File(tr.getFolder().getPath() + File.separator + name); FileUtil.copyDirectory(folder, dest); FileUtil.deltree(folder, true); } catch (Exception e) { @@ -333,9 +360,23 @@ public Long getMaxAgeSeconds(Auth arg0) { return (long)60; } + /** + * Creates a sub-folder named {@code name} and locks it. The lock interface only allows + * {@link NotAuthorizedException}, so an invalid name is reported as a runtime error. + * + * @param name the folder name received from the WebDAV client + * @param timeout the requested lock timeout + * @param lockInfo the requested lock details + * @return the lock token of the created folder + * @throws NotAuthorizedException if the lock is not authorized + */ public LockToken createAndLock(String name, LockTimeout timeout, LockInfo lockInfo) throws NotAuthorizedException { - createCollection(name); + try { + createCollection(name); + } catch (BadRequestException e) { + throw new DotRuntimeException(e.getReason(), e); + } return lock(timeout, lockInfo).getLockToken(); } diff --git a/dotcms-integration/src/test/java/com/dotcms/MainSuite3a.java b/dotcms-integration/src/test/java/com/dotcms/MainSuite3a.java index bb896aee5643..cb6195c2d67a 100644 --- a/dotcms-integration/src/test/java/com/dotcms/MainSuite3a.java +++ b/dotcms-integration/src/test/java/com/dotcms/MainSuite3a.java @@ -139,7 +139,8 @@ SystemEventsReconciliationIntegrationTest.class, SystemEventsRetentionIntegrationTest.class, com.dotcms.rest.api.v1.portlet.PortletResourceIntegrationTest.class, - LayoutResourceIntegrationTest.class + LayoutResourceIntegrationTest.class, + com.dotmarketing.webdav.DotWebdavTempPathContainmentTest.class }) public class MainSuite3a { diff --git a/dotcms-integration/src/test/java/com/dotmarketing/webdav/DotWebdavTempPathContainmentTest.java b/dotcms-integration/src/test/java/com/dotmarketing/webdav/DotWebdavTempPathContainmentTest.java new file mode 100644 index 000000000000..c760f53cd894 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotmarketing/webdav/DotWebdavTempPathContainmentTest.java @@ -0,0 +1,446 @@ +package com.dotmarketing.webdav; + +import com.bradmcevoy.http.Auth; +import com.bradmcevoy.http.CollectionResource; +import com.bradmcevoy.http.HttpManager; +import com.bradmcevoy.http.Request; +import com.bradmcevoy.http.Resource; +import com.bradmcevoy.http.exceptions.BadRequestException; +import com.dotcms.datagen.FolderDataGen; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.util.IntegrationTestInitService; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.portlets.folders.model.Folder; +import com.dotmarketing.util.ConfigUtils; +import com.liferay.util.FileUtil; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.After; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +/** + * WebDAV temp storage must only read and write strictly inside its own temp directory + * ({@link DotWebdavHelper#getTempDir()}), a dedicated sub-folder of the asset temp path. Paths with a + * {@code .} or {@code ..} segment are rejected, and a name that reaches temp storage must be a single + * plain path segment. Rejected names surface as {@link BadRequestException} (HTTP 400) and leave the + * filesystem untouched. In-bounds names, including the dot-prefixed transient files WebDAV clients + * create, keep working. + */ +public class DotWebdavTempPathContainmentTest { + + private static final byte[] CONTENT = "content".getBytes(StandardCharsets.UTF_8); + + private final DotWebdavHelper helper = new DotWebdavHelper(); + private final List scratchFolders = new ArrayList<>(); + + /** Starts the integration environment that {@link DotWebdavHelper} depends on. */ + @BeforeClass + public static void prepare() throws Exception { + IntegrationTestInitService.getInstance().init(); + } + + /** Removes every scratch folder the test created. */ + @After + public void cleanUp() { + scratchFolders.forEach(folder -> FileUtil.deltree(folder, true)); + } + + /** Creates an empty, uniquely named folder inside the WebDAV temp directory for one test. */ + private File newScratchFolder() { + return newFolder(helper.getTempDir(), "containment-"); + } + + /** + * Creates an empty, uniquely named folder under {@code parent}, removed after the test. + * + * @param parent the folder that receives the new folder + * @param prefix the start of the new folder's name + * @return the new folder + */ + private File newFolder(final File parent, final String prefix) { + final File folder = new File(parent, prefix + UUID.randomUUID()); + Assert.assertTrue(folder.mkdirs()); + scratchFolders.add(folder); + return folder; + } + + /** + * Tells whether {@code candidate} resolves to a descendant of {@code root} once {@code ..} and + * symlinks are resolved. {@code root} itself does not count as inside. + */ + private static boolean isStrictlyInside(final File root, final File candidate) throws IOException { + return candidate.getCanonicalPath().startsWith(root.getCanonicalPath() + File.separator); + } + + /** + * Runs {@code action} as if it were handling a WebDAV request authenticated as the system user, + * which is what the resource classes read the current user from. + * + * @param action the code to run + * @return what {@code action} returns + */ + private static T asWebdavRequest(final Callable action) throws Exception { + try (MockedStatic http = Mockito.mockStatic(HttpManager.class)) { + final Request request = Mockito.mock(Request.class); + final Auth auth = Mockito.mock(Auth.class); + Mockito.when(auth.getTag()).thenReturn(APILocator.getUserAPI().getSystemUser()); + Mockito.when(request.getAuthorization()).thenReturn(auth); + http.when(HttpManager::request).thenReturn(request); + return action.call(); + } + } + + /** + * Asserts that {@code action}, run inside a WebDAV request, fails with + * {@link BadRequestException}. + */ + private static void assertBadRequest(final String message, final Callable action) throws Exception { + asWebdavRequest(() -> Assert.assertThrows(message, BadRequestException.class, action::call)); + } + + /** Returns the names of every file and folder under {@code root}, at any depth. */ + private static List namesUnder(final File root) throws IOException { + if (!root.exists()) { + return List.of(); + } + try (Stream paths = Files.walk(root.toPath())) { + return paths.filter(path -> !path.equals(root.toPath())) + .map(path -> path.getFileName().toString()) + .collect(Collectors.toList()); + } + } + + /** WebDAV temp storage lives in its own sub-folder of the asset temp path, not in the path itself. */ + @Test + public void getTempDir_is_a_dedicated_sub_folder_of_the_asset_temp_path() throws IOException { + final File assetTemp = new File(ConfigUtils.getAssetTempPath()); + Assert.assertTrue(isStrictlyInside(assetTemp, helper.getTempDir())); + } + + /** A request path that climbs out of the temp directory with {@code ..} resolves to nothing. */ + @Test + public void loadTempFile_returns_null_for_a_path_outside_the_temp_dir() { + Assert.assertNull(helper.loadTempFile("/webdav/working/1/../outside.txt")); + } + + /** A request path that resolves to the temp directory itself is not a valid temp resource. */ + @Test + public void loadTempFile_returns_null_for_the_temp_dir_itself() { + Assert.assertNull(helper.loadTempFile("/webdav/working/1/somehost/..")); + } + + /** + * A request path whose {@code ..} segment would land on another folder that still sits inside + * the temp directory resolves to nothing: the segment alone is enough to reject it. + */ + @Test + public void loadTempFile_returns_null_for_a_dot_segment_that_stays_inside_the_temp_dir() { + final File other = newScratchFolder(); + Assert.assertNull(helper.loadTempFile("/webdav/working/1/somehost/../" + other.getName() + "/.x")); + Assert.assertNull(helper.loadTempFile("/webdav/working/1/somehost/./.x")); + } + + /** + * A folder another feature keeps in the asset temp path cannot be reached through WebDAV, with + * or without a {@code ..} segment: WebDAV paths resolve under its own sub-folder. + */ + @Test + public void loadTempFile_never_resolves_a_folder_of_another_feature() throws IOException { + final File otherFeature = newFolder(new File(ConfigUtils.getAssetTempPath()), "temp_"); + final File otherFile = new File(otherFeature, ".x"); + Files.write(otherFile.toPath(), CONTENT); + + Assert.assertNull(helper.loadTempFile("/webdav/working/1/somehost/../" + otherFeature.getName() + "/.x")); + Assert.assertNull(helper.loadTempFile("/webdav/working/1/../../" + otherFeature.getName() + "/.x")); + + final File direct = helper.loadTempFile("/webdav/working/1/" + otherFeature.getName() + "/.x"); + Assert.assertNotNull(direct); + Assert.assertTrue(isStrictlyInside(helper.getTempDir(), direct)); + Assert.assertNotEquals(otherFile.getCanonicalPath(), direct.getCanonicalPath()); + Assert.assertFalse(direct.exists()); + } + + /** + * Dot-prefixed transient files that WebDAV clients create while saving still resolve, inside the + * temp directory. + */ + @Test + public void loadTempFile_allows_an_in_bounds_dot_prefixed_temp_file() throws IOException { + final File resolved = helper.loadTempFile("/webdav/working/1/demo.dotcms.com/.inflight.txt"); + Assert.assertNotNull(resolved); + Assert.assertTrue(isStrictlyInside(helper.getTempDir(), resolved)); + } + + /** + * A symlink inside the temp directory that points outside it does not make its target reachable: + * reads resolve to nothing and writes are refused. + */ + @Test + public void a_symlink_pointing_outside_the_temp_dir_is_not_followed() throws IOException { + final File outside = newFolder(new File(ConfigUtils.getAssetTempPath()), "outside-"); + final File scratch = newScratchFolder(); + Files.createSymbolicLink(new File(scratch, "link").toPath(), outside.toPath()); + + Assert.assertNull(helper.loadTempFile("/webdav/working/1/" + scratch.getName() + "/link/.x")); + Assert.assertThrows(IOException.class, + () -> helper.createTempFile("/" + scratch.getName() + "/link/.x")); + Assert.assertThrows(RuntimeException.class, + () -> helper.createTempFolder("/webdav/working/1/" + scratch.getName() + "/link/.dir")); + Assert.assertArrayEquals(new String[0], outside.list()); + } + + /** A folder path that climbs out of the temp directory is rejected and no folder is created outside. */ + @Test + public void createTempFolder_rejects_a_path_outside_the_temp_dir() { + final File outside = new File(helper.getTempDir().getParentFile(), "outside-dir"); + try { + Assert.assertThrows(RuntimeException.class, + () -> helper.createTempFolder("/webdav/working/1/../outside-dir")); + Assert.assertFalse(outside.exists()); + } finally { + if (outside.exists()) { + FileUtil.deltree(outside, true); + } + } + } + + /** A file path that climbs out of the temp directory is rejected and no file is created outside. */ + @Test + public void createTempFile_rejects_a_path_outside_the_temp_dir() { + final File outside = new File(helper.getTempDir().getParentFile(), "outside-file.txt"); + try { + Assert.assertThrows(IOException.class, () -> helper.createTempFile("/../outside-file.txt")); + Assert.assertFalse(outside.exists()); + } finally { + if (outside.exists()) { + Assert.assertTrue(outside.delete()); + } + } + } + + /** + * Uploading into a temp folder with a name that is not a single path segment fails with HTTP 400 + * and writes nothing. + */ + @Test + public void createNew_rejects_a_name_that_is_not_a_plain_segment() { + final File scratch = newScratchFolder(); + final File folder = new File(scratch, "folder"); + Assert.assertTrue(folder.mkdirs()); + final TempFolderResourceImpl resource = new TempFolderResourceImpl(folder.getPath(), folder, false); + + for (final String name : new String[]{"..", ".", "a\\b"}) { + Assert.assertThrows("name: " + name, BadRequestException.class, + () -> resource.createNew(name, new ByteArrayInputStream(CONTENT), + (long) CONTENT.length, "text/plain")); + } + Assert.assertArrayEquals(new String[0], folder.list()); + } + + /** Uploading into a temp folder with a plain name still writes the content into that folder. */ + @Test + public void createNew_writes_a_plain_name_inside_the_folder() throws Exception { + final File scratch = newScratchFolder(); + final TempFolderResourceImpl resource = new TempFolderResourceImpl(scratch.getPath(), scratch, false); + + resource.createNew("plain.txt", new ByteArrayInputStream(CONTENT), (long) CONTENT.length, "text/plain"); + + final File written = new File(scratch, "plain.txt"); + Assert.assertTrue(written.isFile()); + Assert.assertArrayEquals(CONTENT, Files.readAllBytes(written.toPath())); + } + + /** Creating a temp sub-folder named {@code ..} or {@code .} fails with HTTP 400 and creates nothing. */ + @Test + public void createCollection_rejects_a_name_that_is_not_a_plain_segment() { + final File scratch = newScratchFolder(); + final File folder = new File(scratch, "folder"); + Assert.assertTrue(folder.mkdirs()); + final TempFolderResourceImpl resource = new TempFolderResourceImpl(folder.getPath(), folder, false); + + for (final String name : new String[]{"..", "."}) { + Assert.assertThrows("name: " + name, BadRequestException.class, + () -> resource.createCollection(name)); + } + Assert.assertArrayEquals(new String[0], folder.list()); + Assert.assertArrayEquals(new String[]{"folder"}, scratch.list()); + } + + /** Copying a temp file to a {@code ..} destination name fails with HTTP 400 and writes nothing. */ + @Test + public void copyTo_rejects_a_name_that_is_not_a_plain_segment() throws Exception { + assertCopyOrMoveRejected(true); + } + + /** + * Moving a temp file to a {@code ..} destination name fails with HTTP 400 and leaves the source + * in place. + */ + @Test + public void moveTo_rejects_a_name_that_is_not_a_plain_segment() throws Exception { + assertCopyOrMoveRejected(false); + } + + /** + * Runs a copy or a move of a temp file to the destination name {@code ..} and checks that it is + * rejected with {@link BadRequestException}, the source file is untouched, and the destination + * folder stays empty. + * + * @param copy {@code true} to copy, {@code false} to move + */ + private void assertCopyOrMoveRejected(final boolean copy) throws Exception { + final File scratch = newScratchFolder(); + final File sourceFolder = new File(scratch, "source"); + final File targetFolder = new File(new File(scratch, "target"), "inner"); + Assert.assertTrue(sourceFolder.mkdirs()); + Assert.assertTrue(targetFolder.mkdirs()); + final File sourceFile = new File(sourceFolder, "file.txt"); + Files.write(sourceFile.toPath(), CONTENT); + + final TempFileResourceImpl source = new TempFileResourceImpl(sourceFile, sourceFile.getPath(), false); + final TempFolderResourceImpl target = new TempFolderResourceImpl(targetFolder.getPath(), targetFolder, false); + + assertBadRequest("copy: " + copy, () -> { + if (copy) { + source.copyTo(target, ".."); + } else { + source.moveTo(target, ".."); + } + return null; + }); + + Assert.assertTrue(sourceFile.isFile()); + Assert.assertTrue(targetFolder.isDirectory()); + Assert.assertArrayEquals(new String[0], targetFolder.list()); + } + + /** + * Creates a site for one test and registers its WebDAV temp folder for removal afterwards. + * + * @return the new site + */ + private Host newSite() { + final Host site = new SiteDataGen().nextPersisted(); + scratchFolders.add(new File(helper.getTempDir(), site.getHostname())); + return site; + } + + /** + * In a site folder, a temp-style folder name that is not a single segment fails with HTTP 400, + * and a valid one creates the folder in temp storage and returns that folder. + */ + @Test + public void folderResource_createCollection_validates_the_name_and_returns_the_created_folder() + throws Exception { + final Host site = newSite(); + final Folder folder = new FolderDataGen().site(site).nextPersisted(); + final FolderResourceImpl resource = new FolderResourceImpl(folder, + "/webdav/working/1/" + site.getHostname() + folder.getPath()); + final File siteTemp = new File(helper.getTempDir(), site.getHostname()); + + for (final String name : new String[]{"..", "."}) { + assertBadRequest("name: " + name, () -> resource.createCollection(name)); + } + Assert.assertEquals(List.of(), namesUnder(siteTemp)); + + final CollectionResource created = asWebdavRequest(() -> resource.createCollection(".tmpdir")); + assertIsCreatedTempFolder(created, ".tmpdir", siteTemp); + } + + /** + * At a site root, a temp-style folder name that is not a single segment fails with HTTP 400, and + * a valid one creates the folder in temp storage and returns that folder. + */ + @Test + public void hostResource_createCollection_validates_the_name_and_returns_the_created_folder() + throws Exception { + final Host site = newSite(); + final HostResourceImpl resource = new HostResourceImpl("/webdav/working/1/" + site.getHostname()); + final File siteTemp = new File(helper.getTempDir(), site.getHostname()); + + for (final String name : new String[]{"..", "."}) { + assertBadRequest("name: " + name, () -> resource.createCollection(name)); + } + Assert.assertEquals(List.of(), namesUnder(siteTemp)); + + final CollectionResource created = asWebdavRequest(() -> resource.createCollection(".tmpdir")); + assertIsCreatedTempFolder(created, ".tmpdir", siteTemp); + } + + /** + * At the language root, a temp-style folder name that is not a single segment fails with HTTP + * 400, and a valid one creates the folder in temp storage and returns that folder. + */ + @Test + public void languageFolderResource_createCollection_validates_the_name_and_returns_the_created_folder() + throws Exception { + final LanguageFolderResourceImpl resource = new LanguageFolderResourceImpl(""); + final File languagesTemp = new File(new File(helper.getTempDir(), "system"), "languages"); + final String name = ".tmpdir-" + UUID.randomUUID(); + scratchFolders.add(new File(languagesTemp, name)); + final List before = namesUnder(languagesTemp); + + for (final String invalid : new String[]{"..", "."}) { + Assert.assertThrows("name: " + invalid, BadRequestException.class, + () -> resource.createCollection(invalid)); + } + Assert.assertEquals(before, namesUnder(languagesTemp)); + + assertIsCreatedTempFolder(resource.createCollection(name), name, languagesTemp); + } + + /** + * Checks that {@code created} wraps the folder named {@code name} that now exists inside + * {@code parent}, not the parent folder or a path outside temp storage. + */ + private void assertIsCreatedTempFolder(final CollectionResource created, final String name, + final File parent) throws IOException { + Assert.assertTrue(created instanceof TempFolderResourceImpl); + final File folder = ((TempFolderResourceImpl) created).getFolder(); + Assert.assertEquals(name, folder.getName()); + Assert.assertTrue(folder.isDirectory()); + Assert.assertTrue(isStrictlyInside(parent, folder)); + Assert.assertTrue(isStrictlyInside(helper.getTempDir(), folder)); + } + + /** + * Uploading a temp-style file at a site root with a name that is not a single segment fails with + * HTTP 400 and writes nothing; a valid name is written inside temp storage. + */ + @Test + public void basicFolderResource_createNew_validates_the_name_and_writes_inside_temp_storage() + throws Exception { + final Host site = newSite(); + final HostResourceImpl resource = new HostResourceImpl("/webdav/working/1/" + site.getHostname()); + final File siteTemp = new File(helper.getTempDir(), site.getHostname()); + + for (final String name : new String[]{"..", "."}) { + assertBadRequest("name: " + name, () -> resource.createNew(name, + new ByteArrayInputStream(CONTENT), (long) CONTENT.length, "text/plain")); + } + Assert.assertEquals(List.of(), namesUnder(siteTemp)); + + final Resource created = asWebdavRequest(() -> resource.createNew(".inflight.txt", + new ByteArrayInputStream(CONTENT), (long) CONTENT.length, "text/plain")); + Assert.assertTrue(created instanceof TempFileResourceImpl); + final File written = ((TempFileResourceImpl) created).getFile(); + Assert.assertTrue(written.isFile()); + Assert.assertTrue(isStrictlyInside(siteTemp, written)); + Assert.assertArrayEquals(CONTENT, Files.readAllBytes(written.toPath())); + } +} diff --git a/dotcms-postman/src/main/resources/postman/WebDav.postman_collection.json b/dotcms-postman/src/main/resources/postman/WebDav.postman_collection.json index ad1129dd65e3..c33fd5973896 100644 --- a/dotcms-postman/src/main/resources/postman/WebDav.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/WebDav.postman_collection.json @@ -750,6 +750,492 @@ } ], "description": "Deletes the created folder and validate" + }, + { + "name": "Copy and move in temp storage", + "item": [ + { + "name": "Create the folder", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "MKCOL", + "header": [], + "url": { + "raw": "{{serverURL}}/webdav/live/1/{{hostName}}/webdavtemp", + "host": [ + "{{serverURL}}" + ], + "path": [ + "webdav", + "live", + "1", + "{{hostName}}", + "webdavtemp" + ] + }, + "description": "Creates the folder that holds the temp storage used by the requests below." + }, + "response": [] + }, + { + "name": "Create a temp folder", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "MKCOL", + "header": [], + "url": { + "raw": "{{serverURL}}/webdav/live/1/{{hostName}}/webdavtemp/.tempdir", + "host": [ + "{{serverURL}}" + ], + "path": [ + "webdav", + "live", + "1", + "{{hostName}}", + "webdavtemp", + ".tempdir" + ] + }, + "description": "A dot-prefixed folder name goes to WebDAV temp storage, the way clients store their transient files." + }, + "response": [] + }, + { + "name": "Upload a file into the temp folder", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "temp content", + "options": { + "raw": { + "language": "text" + } + } + }, + "url": { + "raw": "{{serverURL}}/webdav/live/1/{{hostName}}/webdavtemp/.tempdir/source.txt", + "host": [ + "{{serverURL}}" + ], + "path": [ + "webdav", + "live", + "1", + "{{hostName}}", + "webdavtemp", + ".tempdir", + "source.txt" + ] + }, + "description": "Uploads the file that the copy and move requests below use as their source." + }, + "response": [] + }, + { + "name": "Copy to a dot-dot destination is rejected", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 400\", function () {", + " pm.response.to.have.status(400);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "COPY", + "header": [ + { + "key": "Destination", + "value": "{{serverURL}}/webdav/live/1/{{hostName}}/webdavtemp/.tempdir/..", + "type": "text" + } + ], + "url": { + "raw": "{{serverURL}}/webdav/live/1/{{hostName}}/webdavtemp/.tempdir/source.txt", + "host": [ + "{{serverURL}}" + ], + "path": [ + "webdav", + "live", + "1", + "{{hostName}}", + "webdavtemp", + ".tempdir", + "source.txt" + ] + }, + "description": "A destination whose last segment is `..` is not a valid name and is rejected with 400." + }, + "response": [] + }, + { + "name": "Move to a dot-dot destination is rejected", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 400\", function () {", + " pm.response.to.have.status(400);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "MOVE", + "header": [ + { + "key": "Destination", + "value": "{{serverURL}}/webdav/live/1/{{hostName}}/webdavtemp/.tempdir/..", + "type": "text" + } + ], + "url": { + "raw": "{{serverURL}}/webdav/live/1/{{hostName}}/webdavtemp/.tempdir/source.txt", + "host": [ + "{{serverURL}}" + ], + "path": [ + "webdav", + "live", + "1", + "{{hostName}}", + "webdavtemp", + ".tempdir", + "source.txt" + ] + }, + "description": "A destination whose last segment is `..` is not a valid name and is rejected with 400." + }, + "response": [] + }, + { + "name": "Validate the source is still in place", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 207\", function () {", + " pm.response.to.have.status(207);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "PROPFIND", + "header": [ + { + "key": "Depth", + "value": "0", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "\n\n \n", + "options": { + "raw": { + "language": "text" + } + } + }, + "url": { + "raw": "{{serverURL}}/webdav/live/1/{{hostName}}/webdavtemp/.tempdir/source.txt", + "host": [ + "{{serverURL}}" + ], + "path": [ + "webdav", + "live", + "1", + "{{hostName}}", + "webdavtemp", + ".tempdir", + "source.txt" + ] + }, + "description": "The rejected copy and move left the source file where it was." + }, + "response": [] + }, + { + "name": "Copy to a plain name", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "COPY", + "header": [ + { + "key": "Destination", + "value": "{{serverURL}}/webdav/live/1/{{hostName}}/webdavtemp/.tempdir/copy.txt", + "type": "text" + } + ], + "url": { + "raw": "{{serverURL}}/webdav/live/1/{{hostName}}/webdavtemp/.tempdir/source.txt", + "host": [ + "{{serverURL}}" + ], + "path": [ + "webdav", + "live", + "1", + "{{hostName}}", + "webdavtemp", + ".tempdir", + "source.txt" + ] + }, + "description": "Copying within temp storage to a plain name still works." + }, + "response": [] + }, + { + "name": "Validate the copy has the source content", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Body is the source content\", function () {", + " pm.expect(pm.response.text()).to.eql(\"temp content\");", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/webdav/live/1/{{hostName}}/webdavtemp/.tempdir/copy.txt", + "host": [ + "{{serverURL}}" + ], + "path": [ + "webdav", + "live", + "1", + "{{hostName}}", + "webdavtemp", + ".tempdir", + "copy.txt" + ] + }, + "description": "The copied file holds the content uploaded to the source." + }, + "response": [] + }, + { + "name": "Move to a plain name", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "MOVE", + "header": [ + { + "key": "Destination", + "value": "{{serverURL}}/webdav/live/1/{{hostName}}/webdavtemp/.tempdir/moved.txt", + "type": "text" + } + ], + "url": { + "raw": "{{serverURL}}/webdav/live/1/{{hostName}}/webdavtemp/.tempdir/copy.txt", + "host": [ + "{{serverURL}}" + ], + "path": [ + "webdav", + "live", + "1", + "{{hostName}}", + "webdavtemp", + ".tempdir", + "copy.txt" + ] + }, + "description": "Moving within temp storage to a plain name still works." + }, + "response": [] + }, + { + "name": "Validate the moved file exists", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 207\", function () {", + " pm.response.to.have.status(207);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "PROPFIND", + "header": [ + { + "key": "Depth", + "value": "0", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "\n\n \n", + "options": { + "raw": { + "language": "text" + } + } + }, + "url": { + "raw": "{{serverURL}}/webdav/live/1/{{hostName}}/webdavtemp/.tempdir/moved.txt", + "host": [ + "{{serverURL}}" + ], + "path": [ + "webdav", + "live", + "1", + "{{hostName}}", + "webdavtemp", + ".tempdir", + "moved.txt" + ] + }, + "description": "The moved file is found under its new name." + }, + "response": [] + }, + { + "name": "Delete the folder", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 204\", function () {", + " pm.response.to.have.status(204);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{serverURL}}/webdav/live/1/{{hostName}}/webdavtemp", + "host": [ + "{{serverURL}}" + ], + "path": [ + "webdav", + "live", + "1", + "{{hostName}}", + "webdavtemp" + ] + }, + "description": "Removes the folder created for these requests." + }, + "response": [] + } + ], + "description": "Copy and move a file in WebDAV temp storage with a real Destination header: a destination that is not a single plain name is rejected with 400 and leaves the source untouched, while plain names keep working." } ], "auth": {