package io.undertow.server.handlers.resource;
import io.undertow.UndertowLogger;
import io.undertow.UndertowMessages;
import io.undertow.util.ETag;
import org.jboss.logging.Logger;
import org.xnio.FileChangeCallback;
import org.xnio.FileChangeEvent;
import org.xnio.FileSystemWatcher;
import org.xnio.OptionMap;
import org.xnio.Xnio;
import java.io.File;
import java.io.IOException;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.TreeSet;
public class PathResourceManager implements ResourceManager {
private static final Logger log = Logger.getLogger(PathResourceManager.class.getName());
private static final boolean DEFAULT_CHANGE_LISTENERS_ALLOWED = !Boolean.getBoolean("io.undertow.disable-file-system-watcher");
private static final long DEFAULT_TRANSFER_MIN_SIZE = 1024;
private static final ETagFunction NULL_ETAG_FUNCTION = new ETagFunction() {
@Override
public ETag generate(Path path) {
return null;
}
};
private final List<ResourceChangeListener> listeners = new ArrayList<>();
private FileSystemWatcher fileSystemWatcher;
protected volatile String base;
protected volatile FileSystem fileSystem;
private final long transferMinSize;
private final boolean caseSensitive;
private final boolean followLinks;
private final TreeSet<String> safePaths = new TreeSet<>();
private final ETagFunction eTagFunction;
private final boolean allowResourceChangeListeners;
public PathResourceManager(final Path base) {
this(base, DEFAULT_TRANSFER_MIN_SIZE, true, false, null);
}
public PathResourceManager(final Path base, long transferMinSize) {
this(base, transferMinSize, true, false, null);
}
public PathResourceManager(final Path base, long transferMinSize, boolean caseSensitive) {
this(base, transferMinSize, caseSensitive, false, null);
}
public PathResourceManager(final Path base, long transferMinSize, boolean followLinks, final String... safePaths) {
this(base, transferMinSize, true, followLinks, safePaths);
}
protected PathResourceManager(long transferMinSize, boolean caseSensitive, boolean followLinks, final String... safePaths) {
this(transferMinSize, caseSensitive, followLinks, DEFAULT_CHANGE_LISTENERS_ALLOWED, safePaths);
}
protected PathResourceManager(long transferMinSize, boolean caseSensitive, boolean followLinks, boolean allowResourceChangeListeners, final String... safePaths) {
this.fileSystem = FileSystems.getDefault();
this.caseSensitive = caseSensitive;
this.followLinks = followLinks;
this.transferMinSize = transferMinSize;
this.allowResourceChangeListeners = allowResourceChangeListeners;
if (this.followLinks) {
if (safePaths == null) {
throw UndertowMessages.MESSAGES.argumentCannotBeNull("safePaths");
}
for (final String safePath : safePaths) {
if (safePath == null) {
throw UndertowMessages.MESSAGES.argumentCannotBeNull("safePaths");
}
}
this.safePaths.addAll(Arrays.asList(safePaths));
}
this.eTagFunction = NULL_ETAG_FUNCTION;
}
public PathResourceManager(final Path base, long transferMinSize, boolean caseSensitive, boolean followLinks, final String... safePaths) {
this(base, transferMinSize, caseSensitive, followLinks, DEFAULT_CHANGE_LISTENERS_ALLOWED, safePaths);
}
public PathResourceManager(final Path base, long transferMinSize, boolean caseSensitive, boolean followLinks, boolean allowResourceChangeListeners, final String... safePaths) {
this(builder()
.setBase(base)
.setTransferMinSize(transferMinSize)
.setCaseSensitive(caseSensitive)
.setFollowLinks(followLinks)
.setAllowResourceChangeListeners(allowResourceChangeListeners)
.setSafePaths(safePaths));
}
private PathResourceManager(Builder builder) {
this.allowResourceChangeListeners = builder.allowResourceChangeListeners;
if (builder.base == null) {
throw UndertowMessages.MESSAGES.argumentCannotBeNull("base");
}
this.fileSystem = builder.base.getFileSystem();
String basePath = builder.base.normalize().toAbsolutePath().toString();
if (!basePath.endsWith(fileSystem.getSeparator())) {
basePath = basePath + fileSystem.getSeparator();
}
this.base = basePath;
this.transferMinSize = builder.transferMinSize;
this.caseSensitive = builder.caseSensitive;
this.followLinks = builder.followLinks;
if (this.followLinks) {
if (builder.safePaths == null) {
throw UndertowMessages.MESSAGES.argumentCannotBeNull("safePaths");
}
for (final String safePath : builder.safePaths) {
if (safePath == null) {
throw UndertowMessages.MESSAGES.argumentCannotBeNull("safePaths");
}
}
this.safePaths.addAll(Arrays.asList(builder.safePaths));
}
this.eTagFunction = builder.eTagFunction;
}
public Path getBasePath() {
return fileSystem.getPath(base);
}
public PathResourceManager setBase(final Path base) {
if (base == null) {
throw UndertowMessages.MESSAGES.argumentCannotBeNull("base");
}
this.fileSystem = base.getFileSystem();
String basePath = base.toAbsolutePath().toString();
if (!basePath.endsWith(fileSystem.getSeparator())) {
basePath = basePath + fileSystem.getSeparator();
}
this.base = basePath;
return this;
}
public PathResourceManager setBase(final File base) {
if (base == null) {
throw UndertowMessages.MESSAGES.argumentCannotBeNull("base");
}
this.fileSystem = FileSystems.getDefault();
String basePath = base.getAbsolutePath();
if (!basePath.endsWith(fileSystem.getSeparator())) {
basePath = basePath + fileSystem.getSeparator();
}
this.base = basePath;
return this;
}
public Resource getResource(final String p) {
String path;
if (p.startsWith("/")) {
path = p.substring(1);
} else {
path = p;
}
try {
Path file = fileSystem.getPath(base, path);
String normalizedFile = file.normalize().toString();
if(!normalizedFile.startsWith(base)) {
if(normalizedFile.length() == base.length() - 1) {
if(!base.startsWith(normalizedFile)) {
log.tracef("Failed to get path resource %s from path resource manager with base %s, as file was outside the base directory", p, base);
return null;
}
} else {
log.tracef("Failed to get path resource %s from path resource manager with base %s, as file was outside the base directory", p, base);
return null;
}
}
if (Files.exists(file)) {
if(path.endsWith("/") && ! Files.isDirectory(file)) {
log.tracef("Failed to get path resource %s from path resource manager with base %s, as path ended with a / but was not a directory", p, base);
return null;
}
boolean followAll = this.followLinks && safePaths.isEmpty();
SymlinkResult symlinkBase = getSymlinkBase(base, file);
if (!followAll && symlinkBase != null && symlinkBase.requiresCheck) {
if (this.followLinks && isSymlinkSafe(file)) {
return getFileResource(file, path, symlinkBase.path, normalizedFile);
} else {
log.tracef("Failed to get path resource %s from path resource manager with base %s, as it was not a safe symlink path", p, base);
return null;
}
} else {
return getFileResource(file, path, symlinkBase == null ? null : symlinkBase.path, normalizedFile);
}
} else {
log.tracef("Failed to get path resource %s from path resource manager with base %s, as the path did not exist", p, base);
return null;
}
} catch (IOException e) {
UndertowLogger.REQUEST_LOGGER.debugf(e, "Invalid path %s", p);
return null;
} catch (SecurityException e) {
UndertowLogger.REQUEST_LOGGER.errorf(e, "Missing JSM permissions for path %s", p);
throw e;
} catch (Exception e) {
UndertowLogger.REQUEST_LOGGER.debugf(e, "Other issue for path %s", p);
return null;
}
}
@Override
public boolean isResourceChangeListenerSupported() {
return allowResourceChangeListeners;
}
@Override
public synchronized void registerResourceChangeListener(ResourceChangeListener listener) {
if(!allowResourceChangeListeners) {
return;
}
if (!fileSystem.equals(FileSystems.getDefault())) {
throw new IllegalStateException("Resource change listeners not supported when using a non-default file system");
}
listeners.add(listener);
if (fileSystemWatcher == null) {
fileSystemWatcher = Xnio.getInstance().createFileSystemWatcher("Watcher for " + base, OptionMap.EMPTY);
fileSystemWatcher.watchPath(new File(base), new FileChangeCallback() {
@Override
public void handleChanges(Collection<FileChangeEvent> changes) {
synchronized (PathResourceManager.this) {
final List<ResourceChangeEvent> events = new ArrayList<>();
for (FileChangeEvent change : changes) {
if (change.getFile().getAbsolutePath().startsWith(base)) {
String path = change.getFile().getAbsolutePath().substring(base.length());
if (File.separatorChar == '\\' && path.contains(File.separator)) {
path = path.replace(File.separatorChar, '/');
}
events.add(new ResourceChangeEvent(path, ResourceChangeEvent.Type.valueOf(change.getType().name())));
}
}
for (ResourceChangeListener listener : listeners) {
listener.handleChanges(events);
}
}
}
});
}
}
@Override
public synchronized void removeResourceChangeListener(ResourceChangeListener listener) {
if(!allowResourceChangeListeners) {
return;
}
listeners.remove(listener);
}
public long getTransferMinSize() {
return transferMinSize;
}
@Override
public synchronized void close() throws IOException {
if (fileSystemWatcher != null) {
fileSystemWatcher.close();
}
}
private SymlinkResult getSymlinkBase(final String base, final Path file) throws IOException {
int nameCount = file.getNameCount();
Path root = fileSystem.getPath(base);
int rootCount = root.getNameCount();
Path f = file;
for (int i = nameCount - 1; i>=0; i--) {
if (SecurityActions.isSymbolicLink(f)) {
return new SymlinkResult(i+1 > rootCount, f);
}
f = f.getParent();
}
return null;
}
private boolean isFileSameCase(final Path file, String normalizeFile) throws IOException {
String canonicalName = file.toRealPath().toString();
return canonicalName.equals(normalizeFile);
}
private boolean isSymlinkSafe(final Path file) throws IOException {
String canonicalPath = file.toRealPath().toString();
for (String safePath : this.safePaths) {
if (safePath.length() > 0) {
if (safePath.startsWith(fileSystem.getSeparator())) {
if (safePath.length() > 0 &&
canonicalPath.length() >= safePath.length() &&
canonicalPath.startsWith(safePath)) {
return true;
}
} else {
String absSafePath = base + fileSystem.getSeparator() + safePath;
Path absSafePathFile = fileSystem.getPath(absSafePath);
String canonicalSafePath = absSafePathFile.toRealPath().toString();
if (canonicalSafePath.length() > 0 &&
canonicalPath.length() >= canonicalSafePath.length() &&
canonicalPath.startsWith(canonicalSafePath)) {
return true;
}
}
}
}
return false;
}
protected PathResource getFileResource(final Path file, final String path, final Path symlinkBase, String normalizedFile) throws IOException {
if (this.caseSensitive) {
if (symlinkBase != null) {
String relative = symlinkBase.relativize(file.normalize()).toString();
String fileResolved = file.toRealPath().toString();
String symlinkBaseResolved = symlinkBase.toRealPath().toString();
if (!fileResolved.startsWith(symlinkBaseResolved)) {
log.tracef("Rejected path resource %s from path resource manager with base %s, as the case did not match actual case of %s", path, base, normalizedFile);
return null;
}
String compare = fileResolved.substring(symlinkBaseResolved.length());
if(compare.startsWith(fileSystem.getSeparator())) {
compare = compare.substring(fileSystem.getSeparator().length());
}
if(relative.startsWith(fileSystem.getSeparator())) {
relative = relative.substring(fileSystem.getSeparator().length());
}
if (relative.equals(compare)) {
log.tracef("Found path resource %s from path resource manager with base %s", path, base);
return new PathResource(file, this, path, eTagFunction.generate(file));
}
log.tracef("Rejected path resource %s from path resource manager with base %s, as the case did not match actual case of %s", path, base, normalizedFile);
return null;
} else if (isFileSameCase(file, normalizedFile)) {
log.tracef("Found path resource %s from path resource manager with base %s", path, base);
return new PathResource(file, this, path, eTagFunction.generate(file));
} else {
log.tracef("Rejected path resource %s from path resource manager with base %s, as the case did not match actual case of %s", path, base, normalizedFile);
return null;
}
} else {
log.tracef("Found path resource %s from path resource manager with base %s", path, base);
return new PathResource(file, this, path, eTagFunction.generate(file));
}
}
private static class SymlinkResult {
public final boolean requiresCheck;
public final Path path;
private SymlinkResult(boolean requiresCheck, Path path) {
this.requiresCheck = requiresCheck;
this.path = path;
}
}
public interface ETagFunction {
ETag generate(Path path);
}
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private Path base;
private long transferMinSize = DEFAULT_TRANSFER_MIN_SIZE;
private boolean caseSensitive = true;
private boolean followLinks = false;
private boolean allowResourceChangeListeners = DEFAULT_CHANGE_LISTENERS_ALLOWED;
private ETagFunction eTagFunction = NULL_ETAG_FUNCTION;
private String[] safePaths;
private Builder() {
}
public Builder setBase(Path base) {
this.base = base;
return this;
}
public Builder setTransferMinSize(long transferMinSize) {
this.transferMinSize = transferMinSize;
return this;
}
public Builder setCaseSensitive(boolean caseSensitive) {
this.caseSensitive = caseSensitive;
return this;
}
public Builder setFollowLinks(boolean followLinks) {
this.followLinks = followLinks;
return this;
}
public Builder setAllowResourceChangeListeners(boolean allowResourceChangeListeners) {
this.allowResourceChangeListeners = allowResourceChangeListeners;
return this;
}
public Builder setETagFunction(ETagFunction eTagFunction) {
this.eTagFunction = eTagFunction;
return this;
}
public Builder setSafePaths(String[] safePaths) {
this.safePaths = safePaths;
return this;
}
public ResourceManager build() {
return new PathResourceManager(this);
}
}
}