Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* 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.myfaces.context;

import java.io.IOException;

/** Exception thrown when a Facelet resource path fails security or mapping validation. */
public class InvalidFileException extends IOException
{
private static final long serialVersionUID = 1L;

/** Categorizes rejection reasons. */
public enum Reason
{
DISALLOWED_SCHEME,
PATH_TRAVERSAL,
INVALID_EXTENSION
}

private final Reason reason;

public InvalidFileException(Reason reason, String message)
{
super(message);
this.reason = reason;
}

public InvalidFileException(Reason reason, String message, Throwable cause)
{
super(message);
initCause(cause);
this.reason = reason;
}

public Reason getReason()
{
return reason;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,25 @@
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URL;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;

import jakarta.el.ELException;
import jakarta.enterprise.inject.spi.BeanManager;
import jakarta.faces.FacesException;
import jakarta.faces.FactoryFinder;
import jakarta.faces.annotation.View;
import jakarta.faces.application.ProjectStage;
import jakarta.faces.application.ViewHandler;
import jakarta.faces.application.ViewResource;
import jakarta.faces.context.ExternalContext;
import jakarta.faces.context.FacesContext;
import org.apache.myfaces.context.InvalidFileException;
import jakarta.faces.view.facelets.Facelet;
import jakarta.faces.view.facelets.FaceletCache;
import jakarta.faces.view.facelets.FaceletCacheFactory;
Expand Down Expand Up @@ -67,6 +73,7 @@ public final class DefaultFaceletFactory extends FaceletFactory
private long _refreshPeriod;
private Map<String, URL> _relativeLocations;
private Map<String, Boolean> _managedFacelet;
private volatile Set<String> _allowedSuffixes;

private FaceletCache<Facelet> _faceletCache;
private AbstractFaceletCache<Facelet> _abstractFaceletCache;
Expand Down Expand Up @@ -249,34 +256,197 @@ public long getRefreshPeriod()
}

/**
* Resolves a path based on the passed URL. If the path starts with '/', then resolve the path against
* {@link jakarta.faces.context.ExternalContext#getResource(java.lang.String)
* jakarta.faces.context.ExternalContext#getResource(java.lang.String)}. Otherwise create a new URL via
* {@link URL#URL(java.net.URL, java.lang.String) URL(URL, String)}.
*
* @param source
* base to resolve from
* @param path
* relative path to the source
* Resolves a path to a URL, validating scheme, traversal, and extension.
* Absolute paths (starting with '/') are resolved via ExternalContext;
* relative paths are resolved against the source URL.
* @param context FacesContext
* @param source base URL for relative resolution
* @param path path to resolve
* @return resolved URL
* @throws IOException
* @throws IOException if path is invalid or not found
*/
public URL resolveURL(FacesContext context, URL source, String path) throws IOException
{
if (path.startsWith("/"))
if (!isAllowedScheme(path))
{
throw new InvalidFileException(InvalidFileException.Reason.DISALLOWED_SCHEME,
"Remote or disallowed scheme in path: " + path);
}

URL resolved;
String normalizedPath;
boolean absoluteContextPath = path.startsWith("/");

if (absoluteContextPath)
{
// Absolute context-relative path via ExternalContext (scoped to WAR by container)
context.getAttributes().put(LAST_RESOURCE_RESOLVED, null);
URL url = resolveURL(context, path);
if (url == null)
resolved = resolveURL(context, path);
if (resolved == null)
{
throw new FileNotFoundException(path + " Not Found in ExternalContext as a Resource");
}
return url;
normalizedPath = path;
}
else
{
return new URL(source, path);
// Relative path resolved against source URL
if (source == null)
{
// Fall back to ExternalContext if no base URL available
resolved = resolveURL(context, path);
if (resolved == null)
{
throw new FileNotFoundException("Cannot resolve relative path '" + path);
}
normalizedPath = path;
}
else
{
resolved = new URL(source, path);
normalizedPath = resolved.getPath();
}
}

// Skip validation in UnitTest stage (uses synthetic paths)
if (context.isProjectStage(ProjectStage.UnitTest))
{
return resolved;
}

// Traversal guard: relative paths must stay within base (absolute paths already scoped by container)
if (!absoluteContextPath && source != null && !isWithinBase(resolved))
{
throw new InvalidFileException(InvalidFileException.Reason.PATH_TRAVERSAL,
"Path escapes application base: " + path);
}

// Extension must be a configured Facelet suffix
if (!mappingAllowed(context, normalizedPath))
{
throw new InvalidFileException(InvalidFileException.Reason.INVALID_EXTENSION,
"Invalid path provided: " + path);
}

return resolved;
}

// Path-validation helpers
private static final Set<String> ALLOWED_SCHEMES = Set.of(
"file","jar","wsjar","zip");

/** Returns true for relative/container schemes; false for all others */
private boolean isAllowedScheme(String path)
{
int colon = path.indexOf(':');

if (colon < 1)
{
return true; // relative path
}

String scheme = path.substring(0, colon).toLowerCase();
return ALLOWED_SCHEMES.contains(scheme);
}

/** Verifies that resolved URL is contained within the application base. */
private boolean isWithinBase(URL resolved)
{
URL base = getBaseUrl();
if (base == null)
{
return true;
}

// Compare path components (scheme-agnostic): extract path after "!" for jar URLs
String basePath = extractResourcePath(base.toExternalForm());
String resolvedPath = extractResourcePath(resolved.toExternalForm());

if (!basePath.endsWith("/"))
{
basePath = basePath + "/";
}
return resolvedPath.startsWith(basePath);
}

/** Extract the in-archive path from jar/wsjar/file URLs (path after "!" for jar URLs). */
private String extractResourcePath(String urlStr)
{
int jarSep = urlStr.indexOf('!');
if (jarSep >= 0)
{
// jar: or wsjar: URL — extract path after "!"
return urlStr.substring(jarSep + 1);
}
// Regular file: URL — extract path component
int fileIdx = urlStr.indexOf("file:");
if (fileIdx >= 0)
{
return urlStr.substring(fileIdx + 5);
}
return urlStr;
}

/** Returns true if normalizedPath ends with a configured Facelet extension. */
private boolean mappingAllowed(FacesContext context, String normalizedPath)
{
if (normalizedPath == null || normalizedPath.isEmpty())
{
return false;
}
int dotIndex = normalizedPath.lastIndexOf('.');
if (dotIndex < 0)
{
return false;
}
String ext = normalizedPath.substring(dotIndex);

if (!getAllowedSuffixes(context).contains(ext))
{
return false;
}
return true;
}

/** Returns cached set of allowed Facelet suffixes built from init parameters. */
private Set<String> getAllowedSuffixes(FacesContext context)
{
if (_allowedSuffixes == null)
{
ExternalContext ec = context.getExternalContext();

String suffixParam = ec.getInitParameter(ViewHandler.FACELETS_SUFFIX_PARAM_NAME);
if (suffixParam == null)
{
suffixParam = ViewHandler.DEFAULT_FACELETS_SUFFIX;
}
Set<String> allowed = new HashSet<>(Arrays.asList(suffixParam.trim().split("\\s+")));

String mappingsParam = ec.getInitParameter(ViewHandler.FACELETS_VIEW_MAPPINGS_PARAM_NAME);
if (mappingsParam == null)
{
mappingsParam = ec.getInitParameter("facelets.VIEW_MAPPINGS");
}
if (mappingsParam != null)
{
for (String token : mappingsParam.split(";"))
{
token = token.trim();
if (token.startsWith("*."))
{
allowed.add(token.substring(1));
}
}
}

if (log.isLoggable(Level.FINE))
{
log.fine("Allowed Facelet suffixes: " + allowed);
}

_allowedSuffixes = allowed;
}
return _allowedSuffixes;
}

/**
Expand Down
Loading
Loading