AbstractFileResourceSet.java
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.catalina.webresources;
import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import org.apache.catalina.LifecycleException;
import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;
import org.apache.tomcat.util.compat.JrePlatform;
import org.apache.tomcat.util.http.RequestUtil;
public abstract class AbstractFileResourceSet extends AbstractResourceSet {
private static final Log log = LogFactory.getLog(AbstractFileResourceSet.class);
protected static final String[] EMPTY_STRING_ARRAY = new String[0];
private File fileBase;
private String absoluteBase;
private String canonicalBase;
private boolean readOnly = false;
protected AbstractFileResourceSet(String internalPath) {
setInternalPath(internalPath);
}
protected final File getFileBase() {
return fileBase;
}
@Override
public void setReadOnly(boolean readOnly) {
this.readOnly = readOnly;
}
@Override
public boolean isReadOnly() {
return readOnly;
}
protected final File file(String name, boolean mustExist) {
if (name.equals("/")) {
name = "";
}
File file = new File(fileBase, name);
// If the requested names ends in '/', the Java File API will return a
// matching file if one exists. This isn't what we want as it is not
// consistent with the Servlet spec rules for request mapping.
if (name.endsWith("/") && file.isFile()) {
return null;
}
// If the file/dir must exist but the identified file/dir can't be read
// then signal that the resource was not found
if (mustExist && !file.canRead()) {
return null;
}
// If allow linking is enabled, files are not limited to being located
// under the fileBase so all further checks are disabled.
if (getRoot().getAllowLinking()) {
return file;
}
// Additional Windows specific checks to handle known problems with
// File.getCanonicalPath()
if (JrePlatform.IS_WINDOWS && isInvalidWindowsFilename(name)) {
return null;
}
// Check that this file is located under the WebResourceSet's base
String canPath = null;
try {
canPath = file.getCanonicalPath();
} catch (IOException e) {
// Ignore
}
if (canPath == null || !canPath.startsWith(canonicalBase)) {
return null;
}
/*
* Ensure that the file is not outside the fileBase. This should not be possible for standard requests (the
* request is normalized early in the request processing) but might be possible for some access via the Servlet
* API (e.g. RequestDispatcher, HTTP/2 push etc.) therefore these checks are retained as an additional safety
* measure. absoluteBase has been normalized so absPath needs to be normalized as well.
*/
String absPath = normalize(file.getAbsolutePath());
if (absPath == null || absoluteBase.length() > absPath.length()) {
return null;
}
// Remove the fileBase location from the start of the paths since that
// was not part of the requested path and the remaining check only
// applies to the request path
absPath = absPath.substring(absoluteBase.length());
canPath = canPath.substring(canonicalBase.length());
// The remaining request path must start with '/' if it has non-zero length
if (canPath.length() > 0 && canPath.charAt(0) != File.separatorChar) {
return null;
}
// Case sensitivity check
// The normalized requested path should be an exact match the equivalent
// canonical path. If it is not, possible reasons include:
// - case differences on case insensitive file systems
// - Windows removing a trailing ' ' or '.' from the file name
//
// In all cases, a mismatch here results in the resource not being
// found
//
// absPath is normalized so canPath needs to be normalized as well
// Can't normalize canPath earlier as canonicalBase is not normalized
if (canPath.length() > 0) {
canPath = normalize(canPath);
}
if (!canPath.equals(absPath)) {
if (!canPath.equalsIgnoreCase(absPath)) {
// Typically means symlinks are in use but being ignored. Given
// the symlink was likely created for a reason, log a warning
// that it was ignored.
logIgnoredSymlink(getRoot().getContext().getName(), absPath, canPath);
}
return null;
}
return file;
}
protected void logIgnoredSymlink(String contextPath, String absPath, String canPath) {
String msg = sm.getString("abstractFileResourceSet.canonicalfileCheckFailed", contextPath, absPath, canPath);
// Log issues with configuration files at a higher level
if (absPath.startsWith("/META-INF/") || absPath.startsWith("/WEB-INF/")) {
log.error(msg);
} else {
log.warn(msg);
}
}
private boolean isInvalidWindowsFilename(String name) {
final int len = name.length();
if (len == 0) {
return false;
}
// This consistently ~10 times faster than the equivalent regular
// expression irrespective of input length.
for (int i = 0; i < len; i++) {
char c = name.charAt(i);
if (c == '\"' || c == '<' || c == '>' || c == ':') {
// These characters are disallowed in Windows file names and
// there are known problems for file names with these characters
// when using File#getCanonicalPath().
// Note: There are additional characters that are disallowed in
// Windows file names but these are not known to cause
// problems when using File#getCanonicalPath().
return true;
}
}
// Windows does not allow file names to end in ' ' unless specific low
// level APIs are used to create the files that bypass various checks.
// File names that end in ' ' are known to cause problems when using
// File#getCanonicalPath().
if (name.charAt(len - 1) == ' ') {
return true;
}
return false;
}
/**
* Return a context-relative path, beginning with a "/", that represents the canonical version of the specified path
* after ".." and "." elements are resolved out. If the specified path attempts to go outside the boundaries of the
* current context (i.e. too many ".." path elements are present), return <code>null</code> instead.
*
* @param path Path to be normalized
*/
private String normalize(String path) {
return RequestUtil.normalize(path, File.separatorChar == '\\');
}
@Override
public URL getBaseUrl() {
try {
return getFileBase().toURI().toURL();
} catch (MalformedURLException e) {
return null;
}
}
/**
* {@inheritDoc}
* <p>
* This is a NO-OP by default for File based resource sets.
*/
@Override
public void gc() {
// NO-OP
}
// -------------------------------------------------------- Lifecycle methods
@Override
protected void initInternal() throws LifecycleException {
fileBase = new File(getBase(), getInternalPath());
checkType(fileBase);
this.absoluteBase = normalize(fileBase.getAbsolutePath());
try {
this.canonicalBase = fileBase.getCanonicalPath();
} catch (IOException e) {
throw new IllegalArgumentException(e);
}
// Need to handle mapping of the file system root as a special case
if ("/".equals(this.absoluteBase)) {
this.absoluteBase = "";
}
if ("/".equals(this.canonicalBase)) {
this.canonicalBase = "";
}
}
protected abstract void checkType(File file);
}