diff --git a/impl/src/main/java/org/apache/myfaces/context/InvalidFileException.java b/impl/src/main/java/org/apache/myfaces/context/InvalidFileException.java new file mode 100644 index 000000000..7a15ac53a --- /dev/null +++ b/impl/src/main/java/org/apache/myfaces/context/InvalidFileException.java @@ -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; + } +} diff --git a/impl/src/main/java/org/apache/myfaces/view/facelets/impl/DefaultFaceletFactory.java b/impl/src/main/java/org/apache/myfaces/view/facelets/impl/DefaultFaceletFactory.java index 2b18271d3..0786fa70a 100644 --- a/impl/src/main/java/org/apache/myfaces/view/facelets/impl/DefaultFaceletFactory.java +++ b/impl/src/main/java/org/apache/myfaces/view/facelets/impl/DefaultFaceletFactory.java @@ -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; @@ -67,6 +73,7 @@ public final class DefaultFaceletFactory extends FaceletFactory private long _refreshPeriod; private Map _relativeLocations; private Map _managedFacelet; + private volatile Set _allowedSuffixes; private FaceletCache _faceletCache; private AbstractFaceletCache _abstractFaceletCache; @@ -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 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 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 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; } /** diff --git a/impl/src/test/java/org/apache/myfaces/view/facelets/impl/DefaultFaceletFactoryPathValidationTest.java b/impl/src/test/java/org/apache/myfaces/view/facelets/impl/DefaultFaceletFactoryPathValidationTest.java new file mode 100644 index 000000000..477aae0cb --- /dev/null +++ b/impl/src/test/java/org/apache/myfaces/view/facelets/impl/DefaultFaceletFactoryPathValidationTest.java @@ -0,0 +1,112 @@ +/* + * 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.view.facelets.impl; + +import java.net.URL; + +import jakarta.faces.application.ViewHandler; +import jakarta.faces.view.ViewDeclarationLanguage; + +import org.apache.myfaces.context.InvalidFileException; +import org.apache.myfaces.test.core.AbstractMyFacesCDIRequestTestCase; +import org.apache.myfaces.view.facelets.FaceletViewDeclarationLanguage; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Verifies that {@link DefaultFaceletFactory#resolveURL} rejects external URLs + * and non-Facelet extensions when running outside the {@code UnitTest} ProjectStage. + * + *

The suite runs under {@code Development} stage (overriding the default + * {@code UnitTest} set by the base class) so the validation code-path is always active.

+ */ +public class DefaultFaceletFactoryPathValidationTest extends AbstractMyFacesCDIRequestTestCase +{ + @Override + protected void setUpWebConfigParams() throws Exception + { + super.setUpWebConfigParams(); + // Replace "UnitTest" so the validation branch in resolveURL is active + servletContext.addInitParameter("jakarta.faces.PROJECT_STAGE", "Development"); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private DefaultFaceletFactory getFactory() + { + ViewDeclarationLanguage vdl = facesContext.getApplication() + .getViewHandler() + .getViewDeclarationLanguage(facesContext, "/test.xhtml"); + return (DefaultFaceletFactory) ((FaceletViewDeclarationLanguage) vdl).getFaceletFactory(); + } + + // ------------------------------------------------------------------------- + // Tests + // ------------------------------------------------------------------------- + + /** + * An external {@code http:} URL must be rejected with + * {@link InvalidFileException.Reason#DISALLOWED_SCHEME}. + */ + @Test + public void testExternalHttpUrlIsRejected() throws Exception + { + // Only a live facesContext is needed — no view rendering required + startViewRequest(null); + + DefaultFaceletFactory factory = getFactory(); + + InvalidFileException ex = Assertions.assertThrows( + InvalidFileException.class, + () -> factory.resolveURL(facesContext, null, + "http://someverybadmaliciouswebsite.com/attack.xhtml"), + "Expected InvalidFileException for external http: URL"); + + Assertions.assertEquals(InvalidFileException.Reason.DISALLOWED_SCHEME, ex.getReason()); + + endRequest(); + } + + /** + * A relative path whose extension is not a configured Facelet suffix must + * be rejected with {@link InvalidFileException.Reason#INVALID_EXTENSION}. + */ + @Test + public void testNonFaceletExtensionIsRejected() throws Exception + { + servletContext.addInitParameter(ViewHandler.FACELETS_SUFFIX_PARAM_NAME, ".xhtml"); + startViewRequest(null); + + DefaultFaceletFactory factory = getFactory(); + + URL webappRoot = getWebappContextURI().toURL(); + URL source = new URL(webappRoot, "views/index.xhtml"); + + InvalidFileException ex = Assertions.assertThrows( + InvalidFileException.class, + () -> factory.resolveURL(facesContext, source, "template.html"), + "Expected InvalidFileException for a .html path"); + + Assertions.assertEquals(InvalidFileException.Reason.INVALID_EXTENSION, ex.getReason()); + + endRequest(); + } +}