mirror of
https://github.com/plan-player-analytics/Plan.git
synced 2025-01-11 19:02:16 +01:00
Test against and identify path traversal vulnerability in other methods
This commit is contained in:
parent
b0a1bc1fb1
commit
f20a04809c
@ -23,6 +23,7 @@ import com.djrapitops.plan.settings.locale.Locale;
|
|||||||
import com.djrapitops.plan.settings.locale.lang.PluginLang;
|
import com.djrapitops.plan.settings.locale.lang.PluginLang;
|
||||||
import com.djrapitops.plan.storage.file.PlanFiles;
|
import com.djrapitops.plan.storage.file.PlanFiles;
|
||||||
import com.djrapitops.plan.storage.file.Resource;
|
import com.djrapitops.plan.storage.file.Resource;
|
||||||
|
import com.djrapitops.plan.utilities.dev.Untrusted;
|
||||||
import com.djrapitops.plan.utilities.logging.ErrorContext;
|
import com.djrapitops.plan.utilities.logging.ErrorContext;
|
||||||
import com.djrapitops.plan.utilities.logging.ErrorLogger;
|
import com.djrapitops.plan.utilities.logging.ErrorLogger;
|
||||||
import net.playeranalytics.plugin.server.PluginLogger;
|
import net.playeranalytics.plugin.server.PluginLogger;
|
||||||
@ -76,12 +77,12 @@ public class ResourceSvc implements ResourceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public WebResource getResource(String pluginName, String fileName, Supplier<WebResource> source) {
|
public WebResource getResource(String pluginName, @Untrusted String fileName, Supplier<WebResource> source) {
|
||||||
checkParams(pluginName, fileName, source);
|
checkParams(pluginName, fileName, source);
|
||||||
return applySnippets(pluginName, fileName, getTheResource(pluginName, fileName, source));
|
return applySnippets(pluginName, fileName, getTheResource(pluginName, fileName, source));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void checkParams(String pluginName, String fileName, Supplier<WebResource> source) {
|
public void checkParams(String pluginName, @Untrusted String fileName, Supplier<WebResource> source) {
|
||||||
if (pluginName == null || pluginName.isEmpty()) {
|
if (pluginName == null || pluginName.isEmpty()) {
|
||||||
throw new IllegalArgumentException("'pluginName' can't be '" + pluginName + "'!");
|
throw new IllegalArgumentException("'pluginName' can't be '" + pluginName + "'!");
|
||||||
}
|
}
|
||||||
@ -93,7 +94,7 @@ public class ResourceSvc implements ResourceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private WebResource applySnippets(String pluginName, String fileName, WebResource resource) {
|
private WebResource applySnippets(String pluginName, @Untrusted String fileName, WebResource resource) {
|
||||||
Map<Position, StringBuilder> byPosition = calculateSnippets(fileName);
|
Map<Position, StringBuilder> byPosition = calculateSnippets(fileName);
|
||||||
if (byPosition.isEmpty()) return resource;
|
if (byPosition.isEmpty()) return resource;
|
||||||
|
|
||||||
@ -129,7 +130,7 @@ public class ResourceSvc implements ResourceService {
|
|||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<Position, StringBuilder> calculateSnippets(String fileName) {
|
private Map<Position, StringBuilder> calculateSnippets(@Untrusted String fileName) {
|
||||||
Map<Position, StringBuilder> byPosition = new EnumMap<>(Position.class);
|
Map<Position, StringBuilder> byPosition = new EnumMap<>(Position.class);
|
||||||
for (Snippet snippet : snippets) {
|
for (Snippet snippet : snippets) {
|
||||||
if (snippet.matches(fileName)) {
|
if (snippet.matches(fileName)) {
|
||||||
@ -139,7 +140,7 @@ public class ResourceSvc implements ResourceService {
|
|||||||
return byPosition;
|
return byPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
public WebResource getTheResource(String pluginName, String fileName, Supplier<WebResource> source) {
|
public WebResource getTheResource(String pluginName, @Untrusted String fileName, Supplier<WebResource> source) {
|
||||||
try {
|
try {
|
||||||
if (resourceSettings.shouldBeCustomized(pluginName, fileName)) {
|
if (resourceSettings.shouldBeCustomized(pluginName, fileName)) {
|
||||||
return getOrWriteCustomized(fileName, source);
|
return getOrWriteCustomized(fileName, source);
|
||||||
@ -153,7 +154,7 @@ public class ResourceSvc implements ResourceService {
|
|||||||
return source.get();
|
return source.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public WebResource getOrWriteCustomized(String fileName, Supplier<WebResource> source) throws IOException {
|
public WebResource getOrWriteCustomized(@Untrusted String fileName, Supplier<WebResource> source) throws IOException {
|
||||||
Optional<Resource> customizedResource = files.getCustomizableResource(fileName);
|
Optional<Resource> customizedResource = files.getCustomizableResource(fileName);
|
||||||
if (customizedResource.isPresent()) {
|
if (customizedResource.isPresent()) {
|
||||||
return readCustomized(customizedResource.get());
|
return readCustomized(customizedResource.get());
|
||||||
@ -170,11 +171,16 @@ public class ResourceSvc implements ResourceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public WebResource writeCustomized(String fileName, Supplier<WebResource> source) throws IOException {
|
public WebResource writeCustomized(@Untrusted String fileName, Supplier<WebResource> source) throws IOException {
|
||||||
WebResource original = source.get();
|
WebResource original = source.get();
|
||||||
byte[] bytes = original.asBytes();
|
byte[] bytes = original.asBytes();
|
||||||
OpenOption[] overwrite = {StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE};
|
OpenOption[] overwrite = {StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE};
|
||||||
Path to = resourceSettings.getCustomizationDirectory().resolve(fileName);
|
@Untrusted Path to = resourceSettings.getCustomizationDirectory().resolve(fileName);
|
||||||
|
if (!to.startsWith(resourceSettings.getCustomizationDirectory())) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Absolute path was given for writing a customized file, " +
|
||||||
|
"writing outside customization directory is prevented for security reasons.");
|
||||||
|
}
|
||||||
Path dir = to.getParent();
|
Path dir = to.getParent();
|
||||||
if (!Files.isSymbolicLink(dir)) Files.createDirectories(dir);
|
if (!Files.isSymbolicLink(dir)) Files.createDirectories(dir);
|
||||||
Files.write(to, bytes, overwrite);
|
Files.write(to, bytes, overwrite);
|
||||||
|
@ -84,7 +84,7 @@ public class ResponseFactory {
|
|||||||
this.addresses = addresses;
|
this.addresses = addresses;
|
||||||
}
|
}
|
||||||
|
|
||||||
public WebResource getResource(String resourceName) {
|
public WebResource getResource(@Untrusted String resourceName) {
|
||||||
return ResourceService.getInstance().getResource("Plan", resourceName,
|
return ResourceService.getInstance().getResource("Plan", resourceName,
|
||||||
() -> files.getResourceFromJar("web/" + resourceName).asWebResource());
|
() -> files.getResourceFromJar("web/" + resourceName).asWebResource());
|
||||||
}
|
}
|
||||||
@ -205,7 +205,7 @@ public class ResponseFactory {
|
|||||||
return StringUtils.replace(resource, "PLAN_BASE_ADDRESS", address);
|
return StringUtils.replace(resource, "PLAN_BASE_ADDRESS", address);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Response cssResponse(String fileName) {
|
public Response cssResponse(@Untrusted String fileName) {
|
||||||
try {
|
try {
|
||||||
String content = UnaryChain.of(getResource(fileName).asString())
|
String content = UnaryChain.of(getResource(fileName).asString())
|
||||||
.chain(theme::replaceThemeColors)
|
.chain(theme::replaceThemeColors)
|
||||||
|
@ -19,6 +19,7 @@ package com.djrapitops.plan.settings.config;
|
|||||||
import com.djrapitops.plan.settings.config.paths.CustomizedFileSettings;
|
import com.djrapitops.plan.settings.config.paths.CustomizedFileSettings;
|
||||||
import com.djrapitops.plan.settings.config.paths.PluginSettings;
|
import com.djrapitops.plan.settings.config.paths.PluginSettings;
|
||||||
import com.djrapitops.plan.storage.file.PlanFiles;
|
import com.djrapitops.plan.storage.file.PlanFiles;
|
||||||
|
import com.djrapitops.plan.utilities.dev.Untrusted;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@ -39,7 +40,7 @@ public class ResourceSettings {
|
|||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean shouldBeCustomized(String plugin, String fileName) {
|
public boolean shouldBeCustomized(String plugin, @Untrusted String fileName) {
|
||||||
if (config.isTrue(CustomizedFileSettings.WEB_DEV_MODE) && config.isFalse(PluginSettings.FRONTEND_BETA)) {
|
if (config.isTrue(CustomizedFileSettings.WEB_DEV_MODE) && config.isFalse(PluginSettings.FRONTEND_BETA)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -48,6 +49,8 @@ public class ResourceSettings {
|
|||||||
fileCustomization.setComment(Collections.singletonList("The files are placed in /Plan/web/ if the setting is 'true' when accessed."));
|
fileCustomization.setComment(Collections.singletonList("The files are placed in /Plan/web/ if the setting is 'true' when accessed."));
|
||||||
|
|
||||||
ConfigNode pluginCustomization = fileCustomization.getNode(plugin).orElseGet(() -> fileCustomization.addNode(plugin));
|
ConfigNode pluginCustomization = fileCustomization.getNode(plugin).orElseGet(() -> fileCustomization.addNode(plugin));
|
||||||
|
|
||||||
|
// No longer untrusted in configuration context, but may contain untrusted data in other context.
|
||||||
String fileNameNonPath = StringUtils.replaceChars(fileName, '.', ',');
|
String fileNameNonPath = StringUtils.replaceChars(fileName, '.', ',');
|
||||||
|
|
||||||
if (pluginCustomization.contains(fileNameNonPath)) {
|
if (pluginCustomization.contains(fileNameNonPath)) {
|
||||||
|
@ -16,6 +16,8 @@
|
|||||||
*/
|
*/
|
||||||
package com.djrapitops.plan.storage.file;
|
package com.djrapitops.plan.storage.file;
|
||||||
|
|
||||||
|
import com.djrapitops.plan.utilities.dev.Untrusted;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileInputStream;
|
import java.io.FileInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@ -35,10 +37,11 @@ import java.util.stream.Stream;
|
|||||||
*/
|
*/
|
||||||
public class FileResource implements Resource {
|
public class FileResource implements Resource {
|
||||||
|
|
||||||
|
@Untrusted
|
||||||
private final String resourceName;
|
private final String resourceName;
|
||||||
private final File file;
|
private final File file;
|
||||||
|
|
||||||
public FileResource(String resourceName, File file) {
|
public FileResource(@Untrusted String resourceName, File file) {
|
||||||
this.resourceName = resourceName;
|
this.resourceName = resourceName;
|
||||||
this.file = file;
|
this.file = file;
|
||||||
}
|
}
|
||||||
@ -54,6 +57,7 @@ public class FileResource implements Resource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@Untrusted
|
||||||
public String getResourceName() {
|
public String getResourceName() {
|
||||||
return resourceName;
|
return resourceName;
|
||||||
}
|
}
|
||||||
|
@ -119,7 +119,7 @@ public class PlanFiles implements SubSystem {
|
|||||||
* @param resourceName Path to the file inside jar/assets/plan/ folder.
|
* @param resourceName Path to the file inside jar/assets/plan/ folder.
|
||||||
* @return a {@link Resource} for accessing the resource.
|
* @return a {@link Resource} for accessing the resource.
|
||||||
*/
|
*/
|
||||||
public Resource getResourceFromJar(String resourceName) {
|
public Resource getResourceFromJar(@Untrusted String resourceName) {
|
||||||
return new JarResource("assets/plan/" + resourceName, getResourceStream);
|
return new JarResource("assets/plan/" + resourceName, getResourceStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,11 +134,11 @@ public class PlanFiles implements SubSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO Customized file logic should be moved to another class so the circular dependency on config can be removed.
|
// TODO Customized file logic should be moved to another class so the circular dependency on config can be removed.
|
||||||
public Optional<Resource> getCustomizableResource(String resourceName) {
|
public Optional<Resource> getCustomizableResource(@Untrusted String resourceName) {
|
||||||
return Optional.ofNullable(findCustomized(resourceName));
|
return Optional.ofNullable(findCustomized(resourceName));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Resource findCustomized(String resourceName) {
|
private Resource findCustomized(@Untrusted String resourceName) {
|
||||||
if (config.get().isTrue(CustomizedFileSettings.WEB_DEV_MODE)) {
|
if (config.get().isTrue(CustomizedFileSettings.WEB_DEV_MODE)) {
|
||||||
// Bypass cache in web developer mode.
|
// Bypass cache in web developer mode.
|
||||||
return getFileResource(resourceName);
|
return getFileResource(resourceName);
|
||||||
@ -147,19 +147,21 @@ public class PlanFiles implements SubSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private FileResource getFileResource(String resourceName) {
|
private FileResource getFileResource(@Untrusted String resourceName) {
|
||||||
return attemptToFind(resourceName)
|
return attemptToFind(resourceName)
|
||||||
.map(found -> new FileResource(resourceName, found))
|
.map(found -> new FileResource(resourceName, found))
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<File> attemptToFind(String resourceName) {
|
public Optional<File> attemptToFind(@Untrusted String resourceName) {
|
||||||
Path dir = config.get().getResourceSettings().getCustomizationDirectory();
|
Path dir = config.get().getResourceSettings().getCustomizationDirectory();
|
||||||
if (dir.toFile().exists() && dir.toFile().isDirectory()) {
|
if (dir.toFile().exists() && dir.toFile().isDirectory()) {
|
||||||
Path asPath = dir.resolve(resourceName);
|
// Path may be absolute due to resolving untrusted path
|
||||||
|
@Untrusted Path asPath = dir.resolve(resourceName);
|
||||||
if (!asPath.startsWith(dir)) {
|
if (!asPath.startsWith(dir)) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
// Now it should be trustworthy
|
||||||
File found = asPath.toFile();
|
File found = asPath.toFile();
|
||||||
if (found.exists()) {
|
if (found.exists()) {
|
||||||
return Optional.of(found);
|
return Optional.of(found);
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
package com.djrapitops.plan.storage.file;
|
package com.djrapitops.plan.storage.file;
|
||||||
|
|
||||||
import com.djrapitops.plan.delivery.web.resource.WebResource;
|
import com.djrapitops.plan.delivery.web.resource.WebResource;
|
||||||
|
import com.djrapitops.plan.utilities.dev.Untrusted;
|
||||||
import com.google.gson.Gson;
|
import com.google.gson.Gson;
|
||||||
import com.google.gson.reflect.TypeToken;
|
import com.google.gson.reflect.TypeToken;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
@ -38,6 +39,7 @@ public interface Resource {
|
|||||||
*
|
*
|
||||||
* @return Relative file path given to {@link PlanFiles}.
|
* @return Relative file path given to {@link PlanFiles}.
|
||||||
*/
|
*/
|
||||||
|
@Untrusted
|
||||||
String getResourceName();
|
String getResourceName();
|
||||||
|
|
||||||
byte[] asBytes() throws IOException;
|
byte[] asBytes() throws IOException;
|
||||||
|
@ -16,6 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.djrapitops.plan.storage.file;
|
package com.djrapitops.plan.storage.file;
|
||||||
|
|
||||||
|
import com.djrapitops.plan.utilities.dev.Untrusted;
|
||||||
import com.github.benmanes.caffeine.cache.Cache;
|
import com.github.benmanes.caffeine.cache.Cache;
|
||||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
|
||||||
@ -40,7 +41,7 @@ public class ResourceCache {
|
|||||||
// Static class
|
// Static class
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Resource getOrCache(String resourceName, Supplier<Resource> resourceSupplier) {
|
public static Resource getOrCache(@Untrusted String resourceName, Supplier<Resource> resourceSupplier) {
|
||||||
String found = cache.getIfPresent(resourceName);
|
String found = cache.getIfPresent(resourceName);
|
||||||
if (found == null) {
|
if (found == null) {
|
||||||
Resource created = resourceSupplier.get();
|
Resource created = resourceSupplier.get();
|
||||||
|
@ -0,0 +1,65 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of Player Analytics (Plan).
|
||||||
|
*
|
||||||
|
* Plan is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Lesser General Public License v3 as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Plan is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Lesser General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Lesser General Public License
|
||||||
|
* along with Plan. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.djrapitops.plan.storage.file;
|
||||||
|
|
||||||
|
import com.djrapitops.plan.settings.config.PlanConfig;
|
||||||
|
import com.djrapitops.plan.settings.config.paths.CustomizedFileSettings;
|
||||||
|
import extension.FullSystemExtension;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author AuroraLS3
|
||||||
|
*/
|
||||||
|
@ExtendWith(FullSystemExtension.class)
|
||||||
|
class PlanFilesTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("getFileFromPluginFolder has no Path Traversal vulnerability")
|
||||||
|
void getFileFromPluginFolderDoesNotAllowAbsolutePathTraversal(@TempDir Path tempDir, PlanFiles files) throws IOException {
|
||||||
|
Path testFile = tempDir.resolve("file.db");
|
||||||
|
Files.createDirectories(tempDir.getParent());
|
||||||
|
Files.createFile(testFile);
|
||||||
|
|
||||||
|
File file = files.getFileFromPluginFolder(testFile.toFile().getAbsolutePath());
|
||||||
|
assertNotEquals(testFile.toFile().getAbsolutePath(), file.getAbsolutePath());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("getCustomizableResource has no Path Traversal vulnerability")
|
||||||
|
void getCustomizableResourceDoesNotAllowAbsolutePathTraversal(@TempDir Path tempDir, PlanConfig config, PlanFiles files) throws IOException {
|
||||||
|
config.set(CustomizedFileSettings.PATH, tempDir.resolve("customized").toFile().getAbsolutePath());
|
||||||
|
|
||||||
|
Path testFile = tempDir.resolve("file.db");
|
||||||
|
Files.createDirectories(tempDir.getParent());
|
||||||
|
Files.createFile(testFile);
|
||||||
|
|
||||||
|
Optional<Resource> resource = files.getCustomizableResource(testFile.toFile().getAbsolutePath());
|
||||||
|
assertTrue(resource.isEmpty());
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user