1 package io.undertow.server.handlers.resource;
2
3
4 import io.undertow.UndertowLogger;
5 import io.undertow.UndertowMessages;
6 import io.undertow.util.ETag;
7 import org.jboss.logging.Logger;
8 import org.xnio.FileChangeCallback;
9 import org.xnio.FileChangeEvent;
10 import org.xnio.FileSystemWatcher;
11 import org.xnio.OptionMap;
12 import org.xnio.Xnio;
13
14 import java.io.File;
15 import java.io.IOException;
16 import java.nio.file.FileSystem;
17 import java.nio.file.FileSystems;
18 import java.nio.file.Files;
19 import java.nio.file.Path;
20 import java.util.ArrayList;
21 import java.util.Arrays;
22 import java.util.Collection;
23 import java.util.List;
24 import java.util.TreeSet;
25
26 /**
27  * Serves files from the file system.
28  */

29 public class PathResourceManager implements ResourceManager  {
30
31     private static final Logger log = Logger.getLogger(PathResourceManager.class.getName());
32
33     private static final boolean DEFAULT_CHANGE_LISTENERS_ALLOWED = !Boolean.getBoolean("io.undertow.disable-file-system-watcher");
34     private static final long DEFAULT_TRANSFER_MIN_SIZE = 1024;
35     private static final ETagFunction NULL_ETAG_FUNCTION = new ETagFunction() {
36         @Override
37         public ETag generate(Path path) {
38             return null;
39         }
40     };
41
42     private final List<ResourceChangeListener> listeners = new ArrayList<>();
43
44     private FileSystemWatcher fileSystemWatcher;
45
46     protected volatile String base;
47
48     protected volatile FileSystem fileSystem;
49
50     /**
51      * Size to use direct FS to network transfer (if supported by OS/JDK) instead of read/write
52      */

53     private final long transferMinSize;
54
55     /**
56      * Check to validate caseSensitive issues for specific case-insensitive FS.
57      * @see io.undertow.server.handlers.resource.PathResourceManager#isFileSameCase(java.nio.file.Path, String)
58      */

59     private final boolean caseSensitive;
60
61     /**
62      * Check to allow follow symbolic links
63      */

64     private final boolean followLinks;
65
66     /**
67      * Used if followLinks == true. Set of paths valid to follow symbolic links. If this is empty and followLinks
68      * it true then all links will be followed
69      */

70     private final TreeSet<String> safePaths = new TreeSet<>();
71
72     private final ETagFunction eTagFunction;
73
74     private final boolean allowResourceChangeListeners;
75
76     public PathResourceManager(final Path base) {
77         this(base, DEFAULT_TRANSFER_MIN_SIZE, truefalsenull);
78     }
79
80     public PathResourceManager(final Path base, long transferMinSize) {
81         this(base, transferMinSize, truefalsenull);
82     }
83
84     public PathResourceManager(final Path base, long transferMinSize, boolean caseSensitive) {
85         this(base, transferMinSize, caseSensitive, falsenull);
86     }
87
88     public PathResourceManager(final Path base, long transferMinSize, boolean followLinks, final String... safePaths) {
89         this(base, transferMinSize, true, followLinks, safePaths);
90     }
91
92     protected PathResourceManager(long transferMinSize, boolean caseSensitive, boolean followLinks, final String... safePaths) {
93         this(transferMinSize, caseSensitive, followLinks, DEFAULT_CHANGE_LISTENERS_ALLOWED, safePaths);
94     }
95
96     protected PathResourceManager(long transferMinSize, boolean caseSensitive, boolean followLinks, boolean allowResourceChangeListeners, final String... safePaths) {
97         this.fileSystem = FileSystems.getDefault();
98         this.caseSensitive = caseSensitive;
99         this.followLinks = followLinks;
100         this.transferMinSize = transferMinSize;
101         this.allowResourceChangeListeners = allowResourceChangeListeners;
102         if (this.followLinks) {
103             if (safePaths == null) {
104                 throw UndertowMessages.MESSAGES.argumentCannotBeNull("safePaths");
105             }
106             for (final String safePath : safePaths) {
107                 if (safePath == null) {
108                     throw UndertowMessages.MESSAGES.argumentCannotBeNull("safePaths");
109                 }
110             }
111             this.safePaths.addAll(Arrays.asList(safePaths));
112         }
113         this.eTagFunction = NULL_ETAG_FUNCTION;
114     }
115
116     public PathResourceManager(final Path base, long transferMinSize, boolean caseSensitive, boolean followLinks, final String... safePaths) {
117         this(base, transferMinSize, caseSensitive, followLinks, DEFAULT_CHANGE_LISTENERS_ALLOWED, safePaths);
118     }
119
120     public PathResourceManager(final Path base, long transferMinSize, boolean caseSensitive, boolean followLinks, boolean allowResourceChangeListeners, final String... safePaths) {
121         this(builder()
122                 .setBase(base)
123                 .setTransferMinSize(transferMinSize)
124                 .setCaseSensitive(caseSensitive)
125                 .setFollowLinks(followLinks)
126                 .setAllowResourceChangeListeners(allowResourceChangeListeners)
127                 .setSafePaths(safePaths));
128     }
129
130     private PathResourceManager(Builder builder) {
131         this.allowResourceChangeListeners = builder.allowResourceChangeListeners;
132         if (builder.base == null) {
133             throw UndertowMessages.MESSAGES.argumentCannotBeNull("base");
134         }
135         this.fileSystem = builder.base.getFileSystem();
136         String basePath = builder.base.normalize().toAbsolutePath().toString();
137         if (!basePath.endsWith(fileSystem.getSeparator())) {
138             basePath = basePath + fileSystem.getSeparator();
139         }
140         this.base = basePath;
141         this.transferMinSize = builder.transferMinSize;
142         this.caseSensitive = builder.caseSensitive;
143         this.followLinks = builder.followLinks;
144         if (this.followLinks) {
145             if (builder.safePaths == null) {
146                 throw UndertowMessages.MESSAGES.argumentCannotBeNull("safePaths");
147             }
148             for (final String safePath : builder.safePaths) {
149                 if (safePath == null) {
150                     throw UndertowMessages.MESSAGES.argumentCannotBeNull("safePaths");
151                 }
152             }
153             this.safePaths.addAll(Arrays.asList(builder.safePaths));
154         }
155         this.eTagFunction = builder.eTagFunction;
156     }
157
158     public Path getBasePath() {
159         return fileSystem.getPath(base);
160     }
161
162     public PathResourceManager setBase(final Path base) {
163         if (base == null) {
164             throw UndertowMessages.MESSAGES.argumentCannotBeNull("base");
165         }
166         this.fileSystem = base.getFileSystem();
167         String basePath = base.toAbsolutePath().toString();
168         if (!basePath.endsWith(fileSystem.getSeparator())) {
169             basePath = basePath + fileSystem.getSeparator();
170         }
171         this.base = basePath;
172         return this;
173     }
174
175     public PathResourceManager setBase(final File base) {
176         if (base == null) {
177             throw UndertowMessages.MESSAGES.argumentCannotBeNull("base");
178         }
179         this.fileSystem = FileSystems.getDefault();
180         String basePath = base.getAbsolutePath();
181         if (!basePath.endsWith(fileSystem.getSeparator())) {
182             basePath = basePath + fileSystem.getSeparator();
183         }
184         this.base = basePath;
185         return this;
186     }
187
188     public Resource getResource(final String p) {
189         String path;
190         //base always ends with a /
191         if (p.startsWith("/")) {
192             path = p.substring(1);
193         } else {
194             path = p;
195         }
196         try {
197             Path file = fileSystem.getPath(base, path);
198             String normalizedFile = file.normalize().toString();
199             if(!normalizedFile.startsWith(base)) {
200                 if(normalizedFile.length() == base.length() - 1) {
201                     //special case for the root path, which may not have a trailing slash
202                     if(!base.startsWith(normalizedFile)) {
203                         log.tracef("Failed to get path resource %s from path resource manager with base %s, as file was outside the base directory", p, base);
204                         return null;
205                     }
206                 } else {
207                     log.tracef("Failed to get path resource %s from path resource manager with base %s, as file was outside the base directory", p, base);
208                     return null;
209                 }
210             }
211             if (Files.exists(file)) {
212                 if(path.endsWith("/") && ! Files.isDirectory(file)) {
213                     //UNDERTOW-432 don't return non directories if the path ends with a /
214                     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);
215                     return null;
216                 }
217                 boolean followAll = this.followLinks && safePaths.isEmpty();
218                 SymlinkResult symlinkBase = getSymlinkBase(base, file);
219                 if (!followAll && symlinkBase != null && symlinkBase.requiresCheck) {
220                     if (this.followLinks && isSymlinkSafe(file)) {
221                         return getFileResource(file, path, symlinkBase.path, normalizedFile);
222                     } else {
223                         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);
224                         return null;
225                     }
226                 } else {
227                     return getFileResource(file, path, symlinkBase == null ? null : symlinkBase.path, normalizedFile);
228                 }
229             } else {
230                 log.tracef("Failed to get path resource %s from path resource manager with base %s, as the path did not exist", p, base);
231                 return null;
232             }
233         } catch (Exception e) {
234             UndertowLogger.REQUEST_LOGGER.debugf(e, "Invalid path %s", p);
235             return null;
236         }
237     }
238
239     @Override
240     public boolean isResourceChangeListenerSupported() {
241         return allowResourceChangeListeners;
242     }
243
244     @Override
245     public synchronized void registerResourceChangeListener(ResourceChangeListener listener) {
246         if(!allowResourceChangeListeners) {
247             //by rights we should throw an exception here, but this works around a bug in Wildfly where it just assumes
248             //PathResourceManager supports this. This will be fixed in a later version
249             return;
250         }
251         if (!fileSystem.equals(FileSystems.getDefault())) {
252             throw new IllegalStateException("Resource change listeners not supported when using a non-default file system");
253         }
254         listeners.add(listener);
255         if (fileSystemWatcher == null) {
256             fileSystemWatcher = Xnio.getInstance().createFileSystemWatcher("Watcher for " + base, OptionMap.EMPTY);
257             fileSystemWatcher.watchPath(new File(base), new FileChangeCallback() {
258                 @Override
259                 public void handleChanges(Collection<FileChangeEvent> changes) {
260                     synchronized (PathResourceManager.this) {
261                         final List<ResourceChangeEvent> events = new ArrayList<>();
262                         for (FileChangeEvent change : changes) {
263                             if (change.getFile().getAbsolutePath().startsWith(base)) {
264                                 String path = change.getFile().getAbsolutePath().substring(base.length());
265                                 if (File.separatorChar == '\\' && path.contains(File.separator)) {
266                                     path = path.replace(File.separatorChar, '/');
267                                 }
268                                 events.add(new ResourceChangeEvent(path, ResourceChangeEvent.Type.valueOf(change.getType().name())));
269                             }
270                         }
271                         for (ResourceChangeListener listener : listeners) {
272                             listener.handleChanges(events);
273                         }
274                     }
275                 }
276             });
277         }
278     }
279
280
281     @Override
282     public synchronized void removeResourceChangeListener(ResourceChangeListener listener) {
283         if(!allowResourceChangeListeners) {
284             return;
285         }
286         listeners.remove(listener);
287     }
288
289     public long getTransferMinSize() {
290         return transferMinSize;
291     }
292
293     @Override
294     public synchronized void close() throws IOException {
295         if (fileSystemWatcher != null) {
296             fileSystemWatcher.close();
297         }
298     }
299
300     /**
301      * Returns true is some element of path inside base path is a symlink.
302      */

303     private SymlinkResult getSymlinkBase(final String base, final Path file) throws IOException {
304         int nameCount = file.getNameCount();
305         Path root = fileSystem.getPath(base);
306         int rootCount = root.getNameCount();
307         Path f = file;
308         for (int i = nameCount - 1; i>=0; i--) {
309             if (SecurityActions.isSymbolicLink(f)) {
310                 return new SymlinkResult(i+1 > rootCount, f);
311             }
312             f = f.getParent();
313         }
314
315         return null;
316     }
317
318     /**
319      * Security check for case insensitive file systems.
320      * We make sure the case of the filename matches the case of the request.
321      * This is only a check for case sensitivity, not for non canonical . and ../ which are allowed.
322      *
323      * For example:
324      * file.getName() == "page.jsp" && file.getCanonicalFile().getName() == "page.jsp" should return true
325      * file.getName() == "page.jsp" && file.getCanonicalFile().getName() == "page.JSP" should return false
326      * file.getName() == "./page.jsp" && file.getCanonicalFile().getName() == "page.jsp" should return true
327      */

328     private boolean isFileSameCase(final Path file, String normalizeFile) throws IOException {
329         String canonicalName = file.toRealPath().toString();
330         return canonicalName.equals(normalizeFile);
331     }
332
333     /**
334      * Security check for followSymlinks feature.
335      * Only follows those symbolink links defined in safePaths.
336      */

337     private boolean isSymlinkSafe(final Path file) throws IOException {
338         String canonicalPath = file.toRealPath().toString();
339         for (String safePath : this.safePaths) {
340             if (safePath.length() > 0) {
341                 if (safePath.startsWith(fileSystem.getSeparator())) {
342                     /*
343                      * Absolute path
344                      */

345                     if (safePath.length() > 0 &&
346                             canonicalPath.length() >= safePath.length() &&
347                             canonicalPath.startsWith(safePath)) {
348                         return true;
349                     }
350                 } else {
351                     /*
352                      * In relative path we build the path appending to base
353                      */

354                     String absSafePath = base + fileSystem.getSeparator() + safePath;
355                     Path absSafePathFile = fileSystem.getPath(absSafePath);
356                     String canonicalSafePath = absSafePathFile.toRealPath().toString();
357                     if (canonicalSafePath.length() > 0 &&
358                             canonicalPath.length() >= canonicalSafePath.length() &&
359                             canonicalPath.startsWith(canonicalSafePath)) {
360                         return true;
361                     }
362
363                 }
364             }
365         }
366         return false;
367     }
368
369     /**
370      * Apply security check for case insensitive file systems.
371      */

372     protected PathResource getFileResource(final Path file, final String path, final Path symlinkBase, String normalizedFile) throws IOException {
373         if (this.caseSensitive) {
374             if (symlinkBase != null) {
375                 String relative = symlinkBase.relativize(file.normalize()).toString();
376                 String fileResolved = file.toRealPath().toString();
377                 String symlinkBaseResolved = symlinkBase.toRealPath().toString();
378                 if (!fileResolved.startsWith(symlinkBaseResolved)) {
379                     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);
380                     return null;
381                 }
382                 String compare = fileResolved.substring(symlinkBaseResolved.length());
383                 if(compare.startsWith(fileSystem.getSeparator())) {
384                     compare = compare.substring(fileSystem.getSeparator().length());
385                 }
386                 if(relative.startsWith(fileSystem.getSeparator())) {
387                     relative = relative.substring(fileSystem.getSeparator().length());
388                 }
389                 if (relative.equals(compare)) {
390                     log.tracef("Found path resource %s from path resource manager with base %s", path, base);
391                     return new PathResource(file, this, path, eTagFunction.generate(file));
392                 }
393                 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);
394                 return null;
395             } else if (isFileSameCase(file, normalizedFile)) {
396                 log.tracef("Found path resource %s from path resource manager with base %s", path, base);
397                 return new PathResource(file, this, path, eTagFunction.generate(file));
398             } else {
399                 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);
400                 return null;
401             }
402         } else {
403             log.tracef("Found path resource %s from path resource manager with base %s", path, base);
404             return new PathResource(file, this, path, eTagFunction.generate(file));
405         }
406     }
407
408     private static class SymlinkResult {
409         public final boolean requiresCheck;
410         public final Path path;
411
412         private SymlinkResult(boolean requiresCheck, Path path) {
413             this.requiresCheck = requiresCheck;
414             this.path = path;
415         }
416     }
417
418     public interface ETagFunction {
419
420         /**
421          * Generates an {@link ETag} for the provided {@link Path}.
422          *
423          * @param path Path for which to generate an ETag
424          * @return ETag representing the provided path, or null
425          */

426         ETag generate(Path path);
427     }
428
429     public static Builder builder() {
430         return new Builder();
431     }
432
433     public static final class Builder {
434
435         private Path base;
436         private long transferMinSize = DEFAULT_TRANSFER_MIN_SIZE;
437         private boolean caseSensitive = true;
438         private boolean followLinks = false;
439         private boolean allowResourceChangeListeners = DEFAULT_CHANGE_LISTENERS_ALLOWED;
440         private ETagFunction eTagFunction = NULL_ETAG_FUNCTION;
441         private String[] safePaths;
442
443         private Builder() {
444         }
445
446         public Builder setBase(Path base) {
447             this.base = base;
448             return this;
449         }
450
451         public Builder setTransferMinSize(long transferMinSize) {
452             this.transferMinSize = transferMinSize;
453             return this;
454         }
455
456         public Builder setCaseSensitive(boolean caseSensitive) {
457             this.caseSensitive = caseSensitive;
458             return this;
459         }
460
461         public Builder setFollowLinks(boolean followLinks) {
462             this.followLinks = followLinks;
463             return this;
464         }
465
466         public Builder setAllowResourceChangeListeners(boolean allowResourceChangeListeners) {
467             this.allowResourceChangeListeners = allowResourceChangeListeners;
468             return this;
469         }
470
471         public Builder setETagFunction(ETagFunction eTagFunction) {
472             this.eTagFunction = eTagFunction;
473             return this;
474         }
475
476         public Builder setSafePaths(String[] safePaths) {
477             this.safePaths = safePaths;
478             return this;
479         }
480
481         public ResourceManager build() {
482             return new PathResourceManager(this);
483         }
484     }
485 }
486