Skip to content
Open
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,121 @@
/*
* 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 <code>#include</code> and <code>#parse</code> inside the template
* namespace they are rendered from.
*
* <p>Weblog templates are authored by weblog administrators, whom Roller treats
* as untrusted: the rendering engine runs them under
* <code>SecureUberspector</code> so they cannot reach arbitrary objects. That
* sandbox governs method calls, not resource resolution, so the include
* directives are confined here instead.
*
* <p>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.
*
* <p>Names are also held to the shapes a template can actually take: a stored
* template id, which carries no extension, or a Velocity template file. A name
* that asks for some other kind of file is not a template reference at all, and
* refusing it keeps the directives pointed at templates no matter what a loader
* further down happens to be able to resolve.
*
* <p>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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: trim() is computed twice, and returning the trimmed value means #parse(" $pageId") now resolves a different name than before. Compute it once and return the original unless trimming is intentional.


if (isOutsideNamespace(path) || isNotATemplateName(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.warn("Refusing #" + directiveName + " of '" + path

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once the id bug is fixed this is fine, but note it's unthrottled: any weblog admin can make the server log a WARN per request by leaving a refused include in a public template. Debug, or a once-per-template warning, would be safer.

+ "' from '" + currentResourcePath + "': outside the template namespace");
return null;
}

return path;
}

/**
* Stored templates are resolved by id and carry no extension; theme
* resources are Velocity templates. A name bearing any other extension is
* asking for something that is not a template.
*
* @return true when the name is not one of those two shapes
*/
private boolean isNotATemplateName(String path) {
// Stored template ids arrive as <template>|<deviceType>; the device
// type is a rendition selector, not part of the resource name.
String name = path;
int bar = name.indexOf('|');
if (bar > -1) {
name = name.substring(0, bar);
}

int dot = name.lastIndexOf('.');
if (dot == -1) {
// No extension: a stored template id.
return false;
}
return !name.regionMatches(true, dot, ".vm", 0, 3) || dot != name.length() - 3;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared-theme template names are free-form: the basic theme's stylesheet template is literally named basic-custom.css, and the template guide documents inlining it through #includeTemplate. This check strips |standard, sees .css, and refuses it. Since the resource loaders are already confined to the theme and webapp namespaces, I'd drop the extension check rather than try to enumerate template-name shapes.

}

/**
* @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;
}
// A colon before any slash indicates a scheme or a Windows drive.
int colon = normalized.indexOf(':');
if (colon > -1) {
int slash = normalized.indexOf('/');
return slash == -1 || colon < slash;

@mraible mraible Aug 31, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This refuses every shared-theme template id. SharedThemeFromDir builds ids as themeId + ":" + templateName (lines 270, 368) and weblog.vm's includeTemplate macro does #parse($pageArg.id + '|' + $model.deviceType), so the basic theme's weblog page asks for basic:_day|standard, gets null back, and renders nothing for that fragment. Every bundled theme is affected; only custom (UUID-id) templates pass. A scheme is followed by / (file:///, http://, jar:file:) or is a single drive letter, so something like colon > 1 && (slash == -1 || colon < slash) && normalized.charAt(colon + 1) != '/' is closer, and ThemeResourceLoader is the real authority on what a theme id looks like.

}
return false;
}
}
16 changes: 9 additions & 7 deletions app/src/main/webapp/WEB-INF/velocity.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the class loader gone, RollerVelocity (lines 66-67) still sets resource.loader.class.cache and resource.loader.class.modification_check_interval under themes.reload.mode; those two lines should go in the same change.


# theme resource loader
resource.loader.theme.public.name=theme
Expand All @@ -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
Expand Down Expand Up @@ -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

Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/*
* 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.
*
* <p>Weblog templates are authored by weblog administrators, a role Roller
* treats as untrusted and renders under <code>SecureUberspector</code>. 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("src", "main", "webapp", "WEB-INF", "velocity.properties"),
Paths.get("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)");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cwd-relative, so this only runs from app/; surefire sets project.build.directory for this module (see ApplicationResourcesTest), which would let it run from an IDE rooted at the repo.

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",
"",
" ",
// Not template names: a plain name needs no traversal to reach
// whatever a loader can resolve, so shape is checked too.
"secret.properties",
"web.xml",
"some/config.properties",
"weblog.vm.bak",
"notes.txt",
};
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 = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of these are the shape Roller's own macros pass to #parse. Add basic:_day|standard and basic:basic-custom.css|standard here; both fail against the current handler and would have caught the regression.

"9cf62fb5-9e6e-11f1-8b02-0e09da24358c|standard", // stored template id
"_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.
*
* <p>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");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the temp directory is never deleted; a @TempDir parameter does the cleanup.

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");

assertFalse(render(dir, true, true).contains("REACHED"),
"the include handler still admitted a name that is not a template");
}

/**
* 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();
}
}
Loading
Loading