Skip to content

Commit efa02f1

Browse files
thboileaujlouvel
andauthored
Enforce directory path transversal issue. Issue #1332 (#1415)
* Enforce directory path transversal issue. Issue #1332 * Added unit test case * Fixed remaining issues under Windows - Fixed detection logic for directory inclusion check - toString for Directory now indicates the root URI --------- Co-authored-by: Jerome Louvel <[email protected]>
1 parent 907d3ac commit efa02f1

File tree

7 files changed

+153
-37
lines changed

7 files changed

+153
-37
lines changed

modules/org.restlet.test/src/main/java/org/restlet/test/data/ReferenceTestCase.java

+4
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,10 @@ public void testParsing() {
345345
"/path", null, "frag/ment");
346346
testRef0("http://localhost/path?qu/ery", "http", "localhost", "/path",
347347
"qu/ery", null);
348+
testRef0("file:///path/to/file", "file", "", "/path/to/file",
349+
null, null);
350+
testRef0("file:///c:/path/to/file", "file", "", "/c:/path/to/file",
351+
null, null);
348352

349353
// Test the resolution of relative references
350354
testRef1(base, uri01, uri101);

modules/org.restlet.test/src/main/java/org/restlet/test/resource/DirectoryTestCase.java

+29
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
import org.restlet.engine.header.HeaderConstants;
6363
import org.restlet.engine.io.IoUtils;
6464
import org.restlet.engine.util.ReferenceUtils;
65+
import org.restlet.engine.util.SystemUtils;
6566
import org.restlet.representation.StringRepresentation;
6667
import org.restlet.resource.Directory;
6768
import org.restlet.test.RestletTestCase;
@@ -410,6 +411,34 @@ private void testParentDirectoryInaccessible(MyApplication application, Director
410411
.baseRef(this.webSiteURL)
411412
.handle(GET);
412413
assertEquals(CLIENT_ERROR_FORBIDDEN, response.getStatus());
414+
415+
response = new TestRequest(this.webSiteURL, "%2e%2e%2fprivate.txt")
416+
.baseRef(this.webSiteURL)
417+
.handle(DELETE);
418+
assertEquals(CLIENT_ERROR_FORBIDDEN, response.getStatus());
419+
420+
if (SystemUtils.isWindows()) {
421+
// Try with Windows "\" separator in the URL
422+
response = new TestRequest(this.webSiteURL, "..%5cchild%20dir%5cfile.txt")
423+
.baseRef(this.webSiteURL)
424+
.handle(GET);
425+
// assert no content as the file is empty
426+
assertEquals(SUCCESS_NO_CONTENT, response.getStatus());
427+
428+
response = new TestRequest(this.webSiteURL, "..%5c..%5c..%5cWindows%5cnotepad.exe")
429+
.baseRef(this.webSiteURL)
430+
.handle(GET);
431+
assertEquals(CLIENT_ERROR_FORBIDDEN, response.getStatus());
432+
}
433+
434+
directory.setModifiable(true);
435+
response = new TestRequest(this.webSiteURL, "../child%20dir/file.txt")
436+
.baseRef(this.webSiteURL)
437+
.handle(DELETE);
438+
439+
// assert file is deleted
440+
assertEquals(Status.SUCCESS_NO_CONTENT, response.getStatus());
441+
413442
}
414443

415444
/**

modules/org.restlet/src/main/java/org/restlet/data/Reference.java

+2-2
Original file line numberDiff line numberDiff line change
@@ -1094,11 +1094,11 @@ public String getHostDomain() {
10941094
}
10951095

10961096
/**
1097-
* Returns the optionnally decoded host domain name component.
1097+
* Returns the optionally decoded host domain name component.
10981098
*
10991099
* @param decode
11001100
* Indicates if the result should be decoded using the {@link #decode(String)} method.
1101-
* @return The optionnally decoded host domain name component.
1101+
* @return The optionally decoded host domain name component.
11021102
* @see #getHostDomain()
11031103
*/
11041104
public String getHostDomain(boolean decode) {

modules/org.restlet/src/main/java/org/restlet/engine/local/DirectoryServerResource.java

+49-18
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ public Representation delete() throws ResourceException {
130130
if (this.directoryTarget && !this.indexTarget) {
131131
// let the client handle the directory's deletion
132132
contextRequest.setResourceRef(this.targetUri);
133-
getClientDispatcher().handle(contextRequest, contextResponse);
133+
dispatchRequest(contextRequest, contextResponse);
134134

135135
setStatus(contextResponse.getStatus());
136136
return null;
@@ -144,7 +144,7 @@ public Representation delete() throws ResourceException {
144144
} else if (this.uniqueReference != null) {
145145
// only one representation
146146
contextRequest.setResourceRef(this.uniqueReference);
147-
getClientDispatcher().handle(contextRequest, contextResponse);
147+
dispatchRequest(contextRequest, contextResponse);
148148
setStatus(contextResponse.getStatus());
149149
} else {
150150
// several variants found, but not the right one
@@ -365,18 +365,6 @@ private ReferenceList tryToConvertAsReferenceList(Representation entity) throws
365365
}
366366
}
367367

368-
/**
369-
* Prevent the client from accessing resources in upper directories
370-
*/
371-
public void preventUpperDirectoryAccess() {
372-
String targetUriPath = new Reference(Reference.decode(targetUri))
373-
.normalize()
374-
.toString();
375-
if (!targetUriPath.startsWith(directory.getRootRef().toString())) {
376-
throw new ResourceException(Status.CLIENT_ERROR_FORBIDDEN);
377-
}
378-
}
379-
380368
@Override
381369
protected Representation get() throws ResourceException {
382370
// Content negotiation has been disabled
@@ -468,8 +456,7 @@ public String getDirectoryUri() {
468456
* @return A response with the representation if success.
469457
*/
470458
private Response getRepresentation(String resourceUri) {
471-
return getClientDispatcher().handle(
472-
new Request(Method.GET, resourceUri));
459+
return dispatchRequest(new Request(Method.GET, resourceUri));
473460
}
474461

475462
/**
@@ -489,7 +476,7 @@ protected Response getRepresentation(String resourceUri, MediaType acceptedMedia
489476
request.getClientInfo().accept(acceptedMediaType);
490477
}
491478

492-
return getClientDispatcher().handle(request);
479+
return dispatchRequest(request);
493480
}
494481

495482
/**
@@ -757,6 +744,50 @@ public boolean isFileTarget() {
757744
return this.fileTarget;
758745
}
759746

747+
/**
748+
* Transmit the given request to the clientDispatcher.<br>
749+
* It completes the request's attributes map with the current Directory ("org.restlet.directory" key).
750+
*
751+
* @param request
752+
* The request to send.
753+
* @return The response
754+
*/
755+
private Response dispatchRequest(final Request request) {
756+
final Response response = new Response(request);
757+
dispatchRequest(request, response);
758+
759+
if (response.getStatus().equals(Status.CLIENT_ERROR_FORBIDDEN)) {
760+
throw new ResourceException(response.getStatus());
761+
}
762+
763+
return response;
764+
}
765+
766+
/**
767+
* Transmit the given request to the clientDispatcher.<br>
768+
* It completes the request's attributes map with the current Directory ("org.restlet.directory" key).
769+
*
770+
* @param request
771+
* The request to send.
772+
* @param response
773+
* The related response.
774+
*/
775+
private void dispatchRequest(final Request request, final Response response) {
776+
request.getAttributes().put("org.restlet.directory", this.directory);
777+
getClientDispatcher().handle(request, response);
778+
}
779+
780+
/**
781+
* Prevent the client from accessing resources in upper directories
782+
*/
783+
public void preventUpperDirectoryAccess() {
784+
String targetUriPath = Reference.decode(targetUri);
785+
786+
if (!targetUriPath.startsWith(Reference.decode(directory.getRootRef().toString()))) {
787+
throw new ResourceException(Status.CLIENT_ERROR_FORBIDDEN);
788+
}
789+
}
790+
760791
@Override
761792
public Representation put(Representation entity) throws ResourceException {
762793
if (!this.directory.isModifiable()) {
@@ -772,7 +803,7 @@ public Representation put(Representation entity) throws ResourceException {
772803
contextRequest.setEntity(entity);
773804
Response contextResponse = new Response(contextRequest);
774805
contextRequest.setResourceRef(this.targetUri);
775-
getClientDispatcher().handle(contextRequest, contextResponse);
806+
dispatchRequest(contextRequest, contextResponse);
776807
setStatus(contextResponse.getStatus());
777808

778809
return null;

modules/org.restlet/src/main/java/org/restlet/engine/local/FileClientHelper.java

+63-16
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,11 @@
4444

4545
import java.io.File;
4646
import java.io.FileFilter;
47-
import java.io.FileNotFoundException;
4847
import java.io.IOException;
4948
import java.io.RandomAccessFile;
5049
import java.nio.file.Files;
51-
import java.nio.file.StandardCopyOption;
50+
import java.nio.file.Path;
51+
import java.nio.file.Paths;
5252
import java.util.ArrayList;
5353
import java.util.Collection;
5454
import java.util.Iterator;
@@ -68,6 +68,7 @@
6868
import org.restlet.engine.io.IoUtils;
6969
import org.restlet.representation.Representation;
7070
import org.restlet.representation.Variant;
71+
import org.restlet.resource.Directory;
7172

7273
/**
7374
* Connector to the file resources accessible. Here is the list of parameters
@@ -171,16 +172,27 @@ private boolean checkMetadataConsistency(String fileName, Representation represe
171172

172173
@Override
173174
public Entity getEntity(String decodedPath) {
174-
// Take care of the file separator.
175175
return new FileEntity(
176-
new File(LocalReference.localizePath(decodedPath)),
176+
getFileWithLocalizedPath(decodedPath),
177177
getMetadataService());
178178
}
179179

180+
/**
181+
* Returns a new {@link File} instance for the given path name.<br>
182+
* It ensures to translate the "/" to the local supported file separator (mainly useful for Windows OS).
183+
*
184+
* @param path
185+
* The Path of the file
186+
* @return a new {@link File} instance for the given path name.
187+
*/
188+
private static File getFileWithLocalizedPath(final String path) {
189+
return new File(LocalReference.localizePath(path));
190+
}
191+
180192
/**
181193
* Returns the name of the extension to use to store the temporary content
182194
* while uploading content via the PUT method. Defaults to "tmp".
183-
*
195+
*
184196
* @return The name of the extension to use to store the temporary content.
185197
*/
186198
public String getTemporaryExtension() {
@@ -201,13 +213,18 @@ protected void handleLocal(Request request, Response response,
201213
}
202214

203215
protected void handleFile(Request request, Response response, String decodedPath) {
204-
if (GET.equals(request.getMethod())
216+
final Directory directory = (Directory) request.getAttributes().get("org.restlet.directory");
217+
final File fileWithLocalizedPath = getFileWithLocalizedPath(decodedPath);
218+
219+
if (!isFileInDirectory(directory, fileWithLocalizedPath)) {
220+
response.setStatus(CLIENT_ERROR_FORBIDDEN);
221+
} else if (GET.equals(request.getMethod())
205222
|| HEAD.equals(request.getMethod())) {
206223
handleEntityGet(request, response, getEntity(decodedPath));
207224
} else if (PUT.equals(request.getMethod())) {
208-
handleFilePut(request, response, decodedPath, new File(decodedPath));
225+
handleFilePut(request, response, decodedPath, fileWithLocalizedPath);
209226
} else if (DELETE.equals(request.getMethod())) {
210-
handleFileDelete(response, new File(decodedPath));
227+
handleFileDelete(response, fileWithLocalizedPath);
211228
} else {
212229
response.setStatus(CLIENT_ERROR_METHOD_NOT_ALLOWED);
213230
response.getAllowedMethods().add(GET);
@@ -217,17 +234,48 @@ protected void handleFile(Request request, Response response, String decodedPath
217234
}
218235
}
219236

237+
/**
238+
* Indicates whether the given file is located inside the root directory.
239+
*
240+
* @param directory
241+
* The root directory
242+
* @param file
243+
* The file.
244+
* @return True if the path is located under the root directory, false otherwise.
245+
*/
246+
private static boolean isFileInDirectory(final Directory directory, final File file) {
247+
boolean result = true;
248+
249+
if (directory != null) {
250+
final String fileAbsolute = directory.getRootRef().getPath(true);
251+
final String filePath;
252+
253+
if(fileAbsolute.indexOf(':') == 2 | fileAbsolute.indexOf('|') == 2) {
254+
filePath = fileAbsolute.substring(1);
255+
}else {
256+
filePath = fileAbsolute;
257+
}
258+
259+
final Path rootDirectoryPath = Paths.get(filePath).normalize();
260+
final Path actualFilePath = file.toPath().normalize();
261+
result = !rootDirectoryPath.relativize(actualFilePath).toString().startsWith("..");
262+
}
263+
264+
return result;
265+
}
266+
220267
/**
221268
* Handles a DELETE call for the FILE protocol.
222-
*
269+
*
223270
* @param response
224271
* The response to update.
225272
* @param file
226273
* The file or directory to delete.
227274
*/
228275
protected void handleFileDelete(Response response, File file) {
229276
if (file.isDirectory()) {
230-
if (file.listFiles().length == 0) {
277+
final File[] files = file.listFiles();
278+
if (files == null || files.length == 0) {
231279
if (IoUtils.delete(file)) {
232280
response.setStatus(SUCCESS_NO_CONTENT);
233281
} else {
@@ -436,7 +484,9 @@ private Status updateFileWithPartialContent(Request request, File file, Range ra
436484
return replaceFileByTemporaryFile(request, file, tmp);
437485
} catch (IOException ioe) {
438486
getLogger().log(WARNING, "Unable to create the temporary file", ioe);
439-
cleanTemporaryFileIfUploadNotResumed(tmp);
487+
if (tmp != null) {
488+
cleanTemporaryFileIfUploadNotResumed(tmp);
489+
}
440490
return new Status(SERVER_ERROR_INTERNAL, "Unable to create a temporary file");
441491
}
442492
}
@@ -508,9 +558,6 @@ private Status createFileWithPartialContent(Request request, File file, Range ra
508558
return SUCCESS_CREATED;
509559
}
510560
return SUCCESS_NO_CONTENT;
511-
} catch (FileNotFoundException fnfe) {
512-
getLogger().log(WARNING, "Unable to create the new file", fnfe);
513-
return new Status(SERVER_ERROR_INTERNAL, fnfe);
514561
} catch (IOException ioe) {
515562
getLogger().log(WARNING, "Unable to create the new file", ioe);
516563
return new Status(SERVER_ERROR_INTERNAL, ioe);
@@ -536,7 +583,7 @@ private Status createFile(Request request, File file) {
536583
getLogger().warning(message);
537584
return new Status(SERVER_ERROR_INTERNAL, message);
538585
}
539-
586+
540587
private void cleanTemporaryFileIfUploadNotResumed(File tmp) {
541588
if (tmp.exists() && !isResumeUpload()) {
542589
IoUtils.delete(tmp);
@@ -591,7 +638,7 @@ private void updateFileExtension(StringBuilder fileName, Metadata metadata) {
591638
String extension = getMetadataService().getExtension(metadata);
592639

593640
if (extension != null) {
594-
fileName.append("." + extension);
641+
fileName.append(".").append(extension);
595642
} else {
596643
if (metadata.getParent() != null) {
597644
updateFileExtension(fileName, metadata.getParent());

modules/org.restlet/src/main/java/org/restlet/engine/util/SystemUtils.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ public static int hashCode(Object... objects) {
127127
* @return True if the current operating system is in the Windows family.
128128
*/
129129
public static boolean isWindows() {
130-
return System.getProperty("os.name").toLowerCase().indexOf("win") >= 0;
130+
return System.getProperty("os.name").toLowerCase().contains("win");
131131
}
132132

133133
/**

modules/org.restlet/src/main/java/org/restlet/resource/Directory.java

+5
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,11 @@ public void setRootRef(Reference rootRef) {
353353
this.rootRef = rootRef;
354354
}
355355

356+
@Override
357+
public String toString() {
358+
return getRootRef() == null ? super.toString() : super.toString() + ": " + getRootRef();
359+
}
360+
356361
/**
357362
* Sets the reference comparator based on classic alphabetical order.
358363
*

0 commit comments

Comments
 (0)