Skip to content

Commit c589ef4

Browse files
authored
Security vulnerabilities in rest files endpoint #3804
1 parent 3c917e3 commit c589ef4

4 files changed

Lines changed: 83 additions & 10 deletions

File tree

jmix-localfs/localfs/src/main/java/io/jmix/localfs/LocalFileStorage.java

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,7 @@
1818

1919
import com.google.common.collect.Maps;
2020
import com.google.common.util.concurrent.ThreadFactoryBuilder;
21-
import io.jmix.core.CoreProperties;
22-
import io.jmix.core.FileRef;
23-
import io.jmix.core.FileStorage;
24-
import io.jmix.core.FileStorageException;
25-
import io.jmix.core.TimeSource;
26-
import io.jmix.core.UuidProvider;
21+
import io.jmix.core.*;
2722
import io.jmix.core.annotation.Internal;
2823
import jakarta.annotation.PreDestroy;
2924
import org.apache.commons.io.FileUtils;
@@ -33,6 +28,7 @@
3328
import org.slf4j.Logger;
3429
import org.slf4j.LoggerFactory;
3530
import org.springframework.beans.factory.annotation.Autowired;
31+
import org.springframework.beans.factory.annotation.Value;
3632
import org.springframework.stereotype.Component;
3733

3834
import java.io.File;
@@ -71,6 +67,9 @@ public class LocalFileStorage implements FileStorage {
7167
@Autowired
7268
protected TimeSource timeSource;
7369

70+
@Value("${jmix.localfs.disable-path-check:false}")
71+
protected Boolean disablePathCheck;
72+
7473
protected boolean isImmutableFileStorage;
7574

7675
protected ExecutorService writeExecutor = Executors.newFixedThreadPool(5,
@@ -162,8 +161,21 @@ public long saveStream(FileRef fileRef, InputStream inputStream) {
162161
checkFileExists(path);
163162

164163
long size;
164+
long maxAllowedSize = properties.getMaxFileSize().toBytes();
165165
try (OutputStream outputStream = Files.newOutputStream(path, CREATE_NEW)) {
166-
size = IOUtils.copyLarge(inputStream, outputStream);
166+
size = IOUtils.copyLarge(inputStream, outputStream, 0, maxAllowedSize);
167+
168+
if (size >= maxAllowedSize) {
169+
if (inputStream.read() != IOUtils.EOF) {
170+
outputStream.close();
171+
if (path.toFile().exists()) path.toFile().delete();
172+
173+
throw new FileStorageException(FileStorageException.Type.IO_EXCEPTION,
174+
String.format("File is too large: '%s'. Max file size = %s MB is exceeded but there are unread bytes left.",
175+
path.toAbsolutePath(),
176+
properties.getMaxFileSize().toMegabytes()));
177+
}
178+
}
167179
outputStream.flush();
168180
// writeLog(path, false);
169181
} catch (IOException e) {
@@ -225,6 +237,11 @@ public InputStream openStream(FileRef reference) {
225237
}
226238

227239
try {
240+
if (!Boolean.TRUE.equals(disablePathCheck) && !path.toRealPath().startsWith(root.toRealPath())) {
241+
log.error("File '{}' is outside of root dir '{}': ", path, root);
242+
continue;
243+
}
244+
228245
inputStream = Files.newInputStream(path);
229246
} catch (IOException e) {
230247
log.error("Error opening input stream for " + path, e);

jmix-localfs/localfs/src/main/java/io/jmix/localfs/LocalFileStorageProperties.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
package io.jmix.localfs;
1818

1919
import org.springframework.boot.context.properties.ConfigurationProperties;
20+
import org.springframework.boot.context.properties.bind.DefaultValue;
21+
import org.springframework.util.unit.DataSize;
2022

2123
@ConfigurationProperties(prefix = "jmix.localfs")
2224
public class LocalFileStorageProperties {
@@ -26,9 +28,16 @@ public class LocalFileStorageProperties {
2628
*/
2729
String storageDir;
2830

31+
/**
32+
* Maximum allowable file size.
33+
*/
34+
DataSize maxFileSize;
35+
2936
public LocalFileStorageProperties(
30-
String storageDir) {
37+
String storageDir,
38+
@DefaultValue("100MB") DataSize maxFileSize) {
3139
this.storageDir = storageDir;
40+
this.maxFileSize = maxFileSize;
3241
}
3342

3443
/**
@@ -37,4 +46,11 @@ public LocalFileStorageProperties(
3746
public String getStorageDir() {
3847
return storageDir;
3948
}
49+
50+
/**
51+
* @see #maxFileSize
52+
*/
53+
public DataSize getMaxFileSize() {
54+
return maxFileSize;
55+
}
4056
}

jmix-rest/rest/src/main/java/io/jmix/rest/RestProperties.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import java.util.Collections;
2424
import java.util.Map;
25+
import java.util.Set;
2526

2627
@ConfigurationProperties(prefix = "jmix.rest")
2728
public class RestProperties {
@@ -38,15 +39,22 @@ public class RestProperties {
3839
private final int defaultMaxFetchSize;
3940
private final Map<String, Integer> entityMaxFetchSize;
4041

42+
/**
43+
* File extensions that can be opened for viewing in a browser by replying with 'Content-Disposition=inline' header.
44+
*/
45+
protected Set<String> inlineEnabledFileExtensions;
46+
4147
public RestProperties(
4248
@DefaultValue("false") boolean optimisticLockingEnabled,
4349
@DefaultValue("true") boolean responseFetchPlanEnabled,
4450
@DefaultValue("10000") int defaultMaxFetchSize,
51+
@DefaultValue({"jpg", "png", "jpeg", "pdf"}) Set<String> inlineEnabledFileExtensions,
4552
@Nullable Map<String, Integer> entityMaxFetchSize) {
4653
this.optimisticLockingEnabled = optimisticLockingEnabled;
4754
this.responseFetchPlanEnabled = responseFetchPlanEnabled;
4855
this.defaultMaxFetchSize = defaultMaxFetchSize;
4956
this.entityMaxFetchSize = entityMaxFetchSize == null ? Collections.emptyMap() : entityMaxFetchSize;
57+
this.inlineEnabledFileExtensions = inlineEnabledFileExtensions;
5058
}
5159

5260
/**
@@ -63,6 +71,13 @@ public boolean isResponseFetchPlanEnabled() {
6371
return responseFetchPlanEnabled;
6472
}
6573

74+
/**
75+
* @see #inlineEnabledFileExtensions
76+
*/
77+
public Set<String> getInlineEnabledFileExtensions() {
78+
return inlineEnabledFileExtensions;
79+
}
80+
6681
public int getEntityMaxFetchSize(String entityName) {
6782
return entityMaxFetchSize.getOrDefault(entityName, defaultMaxFetchSize);
6883
}

jmix-rest/rest/src/main/java/io/jmix/rest/impl/controller/FileDownloadController.java

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,16 @@
1717
package io.jmix.rest.impl.controller;
1818

1919
import io.jmix.core.AccessManager;
20-
import io.jmix.core.FileTransferService;
2120
import io.jmix.core.FileRef;
21+
import io.jmix.core.FileTransferService;
2222
import io.jmix.core.Metadata;
23+
import io.jmix.rest.RestProperties;
2324
import io.jmix.rest.accesscontext.RestFileDownloadContext;
2425
import io.jmix.rest.exception.RestAPIException;
26+
import jakarta.servlet.http.HttpServletResponse;
27+
import org.apache.commons.io.FilenameUtils;
28+
import org.apache.commons.lang3.BooleanUtils;
29+
import org.apache.commons.lang3.StringUtils;
2530
import org.slf4j.Logger;
2631
import org.slf4j.LoggerFactory;
2732
import org.springframework.beans.factory.annotation.Autowired;
@@ -31,7 +36,7 @@
3136
import org.springframework.web.bind.annotation.RequestParam;
3237
import org.springframework.web.bind.annotation.RestController;
3338

34-
import jakarta.servlet.http.HttpServletResponse;
39+
import java.util.Set;
3540

3641
/**
3742
* REST API controller that is used for downloading files
@@ -48,6 +53,8 @@ public class FileDownloadController {
4853
protected AccessManager accessManager;
4954
@Autowired
5055
protected FileTransferService fileTransferService;
56+
@Autowired
57+
protected RestProperties restProperties;
5158

5259
@GetMapping
5360
public void downloadFile(@RequestParam String fileRef,
@@ -58,6 +65,7 @@ public void downloadFile(@RequestParam String fileRef,
5865
try {
5966
FileRef fileReference;
6067
fileReference = FileRef.fromString(fileRef);
68+
attachment = resolveAttachmentValue(attachment, fileReference);
6169
fileTransferService.downloadAndWriteResponse(fileReference, fileReference.getStorageName(), attachment, response);
6270
} catch (IllegalArgumentException e) {
6371
throw new RestAPIException("Invalid file reference",
@@ -75,4 +83,21 @@ protected void checkFileDownloadPermission() {
7583
throw new RestAPIException("File download failed", "File download is not permitted", HttpStatus.FORBIDDEN);
7684
}
7785
}
86+
87+
protected boolean resolveAttachmentValue(Boolean attachmentRequestParameterValue, FileRef fileRef) {
88+
if (BooleanUtils.isTrue(attachmentRequestParameterValue)) {
89+
return true;
90+
}
91+
92+
String fileName = fileRef.getFileName();
93+
String extension = FilenameUtils.getExtension(fileName);
94+
if (StringUtils.isEmpty(extension)) {
95+
// No extension - just download
96+
return true;
97+
} else {
98+
// Check if file is allowed to be opened inline
99+
Set<String> inlineEnabledFileExtensions = restProperties.getInlineEnabledFileExtensions();
100+
return !inlineEnabledFileExtensions.contains(StringUtils.lowerCase(extension));
101+
}
102+
}
78103
}

0 commit comments

Comments
 (0)