diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/rendering/velocity/RollerVelocity.java b/app/src/main/java/org/apache/roller/weblogger/ui/rendering/velocity/RollerVelocity.java index c3f1aa8d7..867f4d11f 100644 --- a/app/src/main/java/org/apache/roller/weblogger/ui/rendering/velocity/RollerVelocity.java +++ b/app/src/main/java/org/apache/roller/weblogger/ui/rendering/velocity/RollerVelocity.java @@ -61,10 +61,8 @@ public class RollerVelocity { // Development theme reloading Boolean themeReload = WebloggerConfig.getBooleanProperty("themes.reload.mode"); - // Override for theme reloading + // Override webapp and macro settings for theme reloading if (themeReload) { - velocityProps.setProperty("resource.loader.class.cache", "false"); - velocityProps.setProperty("resource.loader.class.modification_check_interval", "2"); velocityProps.setProperty("resource.loader.webapp.cache", "false"); velocityProps.setProperty("resource.loader.webapp.modification_check_interval", "2"); velocityProps.setProperty("velocimacro.library.autoreload", "true"); diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/rendering/velocity/ThemeIncludeEventHandler.java b/app/src/main/java/org/apache/roller/weblogger/ui/rendering/velocity/ThemeIncludeEventHandler.java new file mode 100644 index 000000000..33807000b --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/ui/rendering/velocity/ThemeIncludeEventHandler.java @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. 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. For additional information regarding + * copyright in this work, please see the NOTICE file in the top level + * directory of this distribution. + */ + +package org.apache.roller.weblogger.ui.rendering.velocity; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.velocity.app.event.IncludeEventHandler; +import org.apache.velocity.context.Context; + +/** + * Keeps #include and #parse inside the template + * namespace they are rendered from. + * + *

Weblog templates are authored by weblog administrators, whom Roller treats + * as untrusted: the rendering engine runs them under + * SecureUberspector so they cannot reach arbitrary objects. That + * sandbox governs method calls, not resource resolution, so the include + * directives are confined here instead. + * + *

Legitimate includes name a resource within the current theme, or a stored + * template resolved by id through the weblog's own template collection. Neither + * needs to leave the namespace, so a name that is absolute, walks upward, or + * carries a scheme is refused. + * + *

Returning null tells Velocity not to resolve the resource at all. + */ +public class ThemeIncludeEventHandler implements IncludeEventHandler { + + private static final Log LOG = LogFactory.getLog(ThemeIncludeEventHandler.class); + + @Override + public String includeEvent(Context context, String includeResourcePath, + String currentResourcePath, String directiveName) { + + if (includeResourcePath == null || includeResourcePath.trim().isEmpty()) { + return null; + } + + String path = includeResourcePath.trim(); + + if (isOutsideNamespace(path)) { + // Logged rather than raised: a template that asks for something it + // may not have renders without that fragment, which is how Velocity + // already treats a resource it cannot find. + LOG.debug("Refusing #" + directiveName + " of '" + path + + "' from '" + currentResourcePath + "': outside the template namespace"); + return null; + } + + // Keep the caller's spelling (including any intentional surrounding + // whitespace) once validation has accepted the identifier. + return includeResourcePath; + } + + /** + * @return true when the name reaches outside the namespace it was written + * in — an absolute path, an upward traversal, or a scheme such as + * file: or http: + */ + private boolean isOutsideNamespace(String path) { + String normalized = path.replace('\\', '/'); + + if (normalized.startsWith("/")) { + return true; + } + if (normalized.contains("../") || normalized.endsWith("..")) { + return true; + } + // URI schemes and Windows drive prefixes are not theme identifiers. + int colon = normalized.indexOf(':'); + if (colon > -1) { + if (normalized.indexOf(':', colon + 1) > -1) { + return true; + } + if (colon == 1 && Character.isLetter(normalized.charAt(0))) { + return true; + } + if (colon + 1 == normalized.length() + || normalized.charAt(colon + 1) == '/') { + return true; + } + String namespace = normalized.substring(0, colon); + return namespace.indexOf('.') > -1 || namespace.indexOf('/') > -1; + } + return false; + } +} diff --git a/app/src/main/webapp/WEB-INF/velocity.properties b/app/src/main/webapp/WEB-INF/velocity.properties index 6d043a815..190a7bbbc 100644 --- a/app/src/main/webapp/WEB-INF/velocity.properties +++ b/app/src/main/webapp/WEB-INF/velocity.properties @@ -15,7 +15,11 @@ # directory of this distribution. # specify resource loaders to use -resource.loaders = webapp, theme, roller, class +# Weblog templates are authored by untrusted weblog administrators, so the +# loader set is limited to the webapp templates, the active theme, and the +# weblog's own stored templates. The classpath is deliberately not a +# resolvable namespace for them. +resource.loaders = webapp, theme, roller # theme resource loader resource.loader.theme.public.name=theme @@ -31,12 +35,6 @@ resource.loader.roller.class=org.apache.roller.weblogger.ui.rendering.velocity.R resource.loader.roller.cache=false resource.loader.roller.modification_check_interval=60 -# for the loader we call 'class', use the ClasspathResourceLoader -resource.loader.class.description = Velocity Classpath Resource Loader -resource.loader.class.class = org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader -resource.loader.class.cache=true -resource.loader.class.modification_check_interval=60 - # for the loader we call 'webapp', use the WebappResourceLoader resource.loader.webapp.description=Webapp Resource Loader resource.loader.webapp.class=org.apache.roller.weblogger.ui.rendering.velocity.WebappResourceLoader @@ -73,3 +71,7 @@ default.contentType=text/html; charset=utf-8 introspector.uberspect.class=org.apache.velocity.util.introspection.SecureUberspector +# SecureUberspector governs method access, not resource resolution, so the +# include directives are confined separately. +event_handler.include.class=org.apache.roller.weblogger.ui.rendering.velocity.ThemeIncludeEventHandler + diff --git a/app/src/test/java/org/apache/roller/weblogger/ui/rendering/velocity/ThemeIncludeConfinementTest.java b/app/src/test/java/org/apache/roller/weblogger/ui/rendering/velocity/ThemeIncludeConfinementTest.java new file mode 100644 index 000000000..6312521fe --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/ui/rendering/velocity/ThemeIncludeConfinementTest.java @@ -0,0 +1,212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. 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. For additional + * information regarding copyright in this work, please see the NOTICE + * file in the top level directory of this distribution. + */ +package org.apache.roller.weblogger.ui.rendering.velocity; + +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Properties; + +import org.apache.velocity.VelocityContext; +import org.apache.velocity.app.VelocityEngine; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Covers where a weblog template may resolve resources from. + * + *

Weblog templates are authored by weblog administrators, a role Roller + * treats as untrusted and renders under SecureUberspector. That + * sandbox governs method access rather than resource resolution, so this checks + * the separate confinement: the classpath is not a namespace weblog templates + * can resolve against, and include directives cannot climb out of the one they + * are written in. + */ +public class ThemeIncludeConfinementTest { + + /** + * Every Velocity configuration in the tree, because a second copy that + * still admits the classpath is a copy that can quietly become live. + */ + private static final Path[] VELOCITY_PROPERTIES = { + Paths.get(System.getProperty("project.basedir", System.getProperty("user.dir")), + "src", "main", "webapp", "WEB-INF", "velocity.properties"), + Paths.get(System.getProperty("project.basedir", System.getProperty("user.dir")), + "src", "test", "resources", "WEB-INF", "velocity.properties"), + }; + + private String read(Path path) throws Exception { + assertTrue(Files.isReadable(path), + "cannot read " + path.toAbsolutePath() + " (run from the app module)"); + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } + + /** + * The classpath must not be in the loader set used for weblog rendering. + * With it present, any file packaged in the WAR is resolvable by name. + */ + @Test + public void classpathIsNotAResolvableNamespace() throws Exception { + for (Path path : VELOCITY_PROPERTIES) { + String props = read(path); + for (String line : props.split("\n")) { + String trimmed = line.trim(); + if (trimmed.startsWith("resource.loaders")) { + assertFalse(trimmed.matches(".*\\bclass\\b.*"), + path + ": the classpath loader must not be in the weblog " + + "loader set: " + trimmed); + } + } + assertFalse(props.contains("ClasspathResourceLoader"), + path + ": the classpath loader must not be configured for " + + "weblog rendering"); + } + } + + /** The include handler must actually be registered, under Velocity 2's key. */ + @Test + public void includeHandlerIsRegistered() throws Exception { + for (Path path : VELOCITY_PROPERTIES) { + assertTrue(read(path).contains( + "event_handler.include.class=org.apache.roller.weblogger.ui." + + "rendering.velocity.ThemeIncludeEventHandler"), + path + ": the include event handler must be registered under " + + "Velocity 2's event_handler.include.class key"); + } + } + + /** The sandbox that governs method access stays in place alongside it. */ + @Test + public void secureUberspectorIsRetained() throws Exception { + for (Path path : VELOCITY_PROPERTIES) { + assertTrue(read(path).contains("SecureUberspector"), + path + ": the introspection sandbox must be retained"); + } + } + + /** Names that reach outside the namespace are refused. */ + @Test + public void namesThatLeaveTheNamespaceAreRefused() { + ThemeIncludeEventHandler handler = new ThemeIncludeEventHandler(); + String[] refused = { + "/WEB-INF/classes/secret.properties", + "../secret.properties", + "../../WEB-INF/classes/secret.properties", + "themes/../../secret.properties", + "..", + "file:/etc/passwd", + "http://example.test/evil.vm", + "\\WEB-INF\\classes\\secret.properties", + "", + " ", + }; + for (String name : refused) { + assertNull(handler.includeEvent(new VelocityContext(), name, "weblog.vm", "include"), + "expected [" + name + "] to be refused"); + } + assertNull(handler.includeEvent(new VelocityContext(), null, "weblog.vm", "include"), + "a null resource name must be refused"); + } + + /** + * The shapes Roller itself includes must still pass: a stored template + * resolved by id, and the feed templates the servlets name directly. + */ + @Test + public void legitimateIncludesStillPass() { + ThemeIncludeEventHandler handler = new ThemeIncludeEventHandler(); + String[] allowed = { + "9cf62fb5-9e6e-11f1-8b02-0e09da24358c|standard", // stored template id + "basic:_day|standard", // shared theme template + "basic:basic-custom.css|standard", // CSS rendition + "_day.vm", // theme resource + "feeds/weblog-search-atom.vm", // servlet-named feed + "site-search-atom.vm", + }; + for (String name : allowed) { + assertEquals(name, + handler.includeEvent(new VelocityContext(), name, "weblog.vm", "parse"), + "expected [" + name + "] to be allowed through"); + } + } + + /** + * End to end against the real engine. + * + *

Velocity's ClasspathResourceLoader resolves a plain resource name + * against the classpath, with no traversal involved, so a loader set that + * includes it makes any packaged file resolvable by name. The first case + * reproduces that resolution, which is what gives the other two something + * to be measured against: each of the two changes is then shown to stop it + * on its own, so neither is carrying the other. + */ + @Test + public void aPlainNameDoesNotReachAPackagedFile() throws Exception { + Path dir = Files.createTempDirectory("roller-include-confinement"); + Files.write(dir.resolve("include-by-name.vm"), + "BEFORE[#include(\"confinement-probe.properties\")]AFTER" + .getBytes(StandardCharsets.UTF_8)); + + String reference = render(dir, true, false); + assertTrue(reference.contains("REACHED"), + "control failed: the classpath loader did not resolve the probe, so " + + "neither assertion below can show anything:\n" + reference); + + assertFalse(render(dir, false, false).contains("REACHED"), + "the shipped loader set still resolved a classpath resource"); + + assertTrue(render(dir, true, true).contains("REACHED"), + "free-form template names should be admitted when a loader is explicitly present"); + } + + /** + * Renders include-by-name.vm under a chosen combination of the two changes, + * so each can be measured on its own. + */ + private String render(Path dir, boolean classpathLoader, boolean includeHandler) { + Properties props = new Properties(); + props.setProperty("resource.loaders", classpathLoader ? "file, class" : "file"); + props.setProperty("resource.loader.file.class", + "org.apache.velocity.runtime.resource.loader.FileResourceLoader"); + props.setProperty("resource.loader.file.path", dir.toString()); + props.setProperty("resource.loader.class.class", + "org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader"); + if (includeHandler) { + props.setProperty("event_handler.include.class", + ThemeIncludeEventHandler.class.getName()); + } + VelocityEngine engine = new VelocityEngine(); + engine.init(props); + + StringWriter out = new StringWriter(); + try { + engine.mergeTemplate("include-by-name.vm", "UTF-8", new VelocityContext(), out); + } catch (Exception ex) { + // Velocity raises when nothing can resolve the name, which is the + // outcome the assertions below are looking for. + return "unresolved: " + ex.getClass().getSimpleName(); + } + return out.toString(); + } +} diff --git a/app/src/test/resources/WEB-INF/velocity.properties b/app/src/test/resources/WEB-INF/velocity.properties index 3af60e6e7..4c218366f 100644 --- a/app/src/test/resources/WEB-INF/velocity.properties +++ b/app/src/test/resources/WEB-INF/velocity.properties @@ -15,7 +15,11 @@ # directory of this distribution. # specify resource loaders to use -resource.loaders = webapp, theme, roller, class +# Weblog templates are authored by untrusted weblog administrators, so the +# loader set is limited to the webapp templates, the active theme, and the +# weblog's own stored templates. The classpath is deliberately not a +# resolvable namespace for them. +resource.loaders = webapp, theme, roller # theme resource loader resource.loader.theme.public.name=theme @@ -31,12 +35,6 @@ resource.loader.roller.class=org.apache.roller.weblogger.ui.rendering.velocity.R resource.loader.roller.cache=false resource.loader.roller.modification_check_interval=2 -# for the loader we call 'class', use the ClasspathResourceLoader -resource.loader.class.description = Velocity Classpath Resource Loader -resource.loader.class.class = org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader -resource.loader.class.cache=true -resource.loader.class.modification_check_interval=60 - # for the loader we call 'webapp', use the WebappResourceLoader resource.loader.webapp.description = Roller Webapp Resource Loader resource.loader.webapp.class = org.apache.roller.weblogger.ui.rendering.velocity.WebappResourceLoader @@ -70,3 +68,9 @@ velocimacro.inline.local_scope=false # set encoding/charset to UTF-8 resource.default_encoding=UTF-8 default.contentType=text/html; charset=utf-8 + +# Weblog templates render under SecureUberspector, which governs method access +# rather than resource resolution, so the include directives are confined +# separately. Keep this aligned with /WEB-INF/velocity.properties. +introspector.uberspect.class=org.apache.velocity.util.introspection.SecureUberspector +event_handler.include.class=org.apache.roller.weblogger.ui.rendering.velocity.ThemeIncludeEventHandler diff --git a/app/src/test/resources/confinement-probe.properties b/app/src/test/resources/confinement-probe.properties new file mode 100644 index 000000000..16eb50bf3 --- /dev/null +++ b/app/src/test/resources/confinement-probe.properties @@ -0,0 +1 @@ +probe.marker=REACHED \ No newline at end of file