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
16 changes: 11 additions & 5 deletions app/src/main/webapp/themes/frontpage/_blogdirectory.vm
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
#if($model.getRequestParameter("letter"))
#set($chosenLetter = $model.getRequestParameter("letter"))
#end
#set($weblogLetterMap = $site.getWeblogHandleLetterMap())

#set($weblogLetterMap = $site.getWeblogHandleLetterMap())
## Accept only a known A-Z key; otherwise render the full listing, exactly
## as a missing parameter does.
#set($requestedLetter = $model.getRequestParameter("letter"))
#if($requestedLetter && $requestedLetter.length() == 1)

@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.

Nit: the length() == 1 check and $candidateLetter collapse to #if($requestedLetter && $weblogLetterMap.containsKey($requestedLetter.toUpperCase())), since every key is one character. Keep the null guard, TreeMap.containsKey(null) throws.

#set($candidateLetter = $requestedLetter.toUpperCase())

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.

Minor: toUpperCase() uses the JVM default locale, so on a Turkish-locale server i becomes İ and ?letter=i is rejected while ?letter=I works. A Locale.ROOT upper-case helper on $utils would avoid it.

#if($weblogLetterMap.containsKey($candidateLetter))
#set($chosenLetter = $candidateLetter)
#end
#end
<div class="letterMap">
<p>
#set($firstLetterDone = 0)
Expand All @@ -22,7 +28,7 @@
</div>

#if($chosenLetter)
<h2 class="pageTitle">Weblogs starting with $chosenLetter</h2>
<h2 class="pageTitle">Weblogs starting with $utils.escapeHTML($chosenLetter)</h2>
#else
<h2 class="pageTitle">All weblogs</h2>
#end
Expand Down
13 changes: 9 additions & 4 deletions app/src/main/webapp/themes/frontpage/directory.vm
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,15 @@

<div id="tabContent">
<div id="directory">
#if($model.getRequestParameter("weblog"))
#set($handle = $model.getRequestParameter("weblog"))
<a href="?letter=$utils.left($handle,1)">Back to blog directory</a>
#set($profileWeblog = $site.getWeblog($handle))
## Render the profile only for a weblog that exists, and build
## the back-link from the resolved weblog's own handle.
#set($profileWeblog = false)

@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.

Nit: nothing sets $profileWeblog before this and Velocity 2.4 assigns null from #set, so the initialiser is a no-op. If it's kept for the ROL-689 precedent in weblog.vm, a comment saying so would help.

#set($requestedHandle = $model.getRequestParameter("weblog"))
#if($requestedHandle)
#set($profileWeblog = $site.getWeblog($requestedHandle))

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.

$site.getWeblog() still receives the raw parameter. JPAWeblogManagerImpl.getWeblogByHandle throws WebloggerException("Invalid handle: '...'") for anything outside [A-Za-z0-9_], and SiteModel.getWeblog logs that at ERROR with a stack trace, so an anonymous loop over /page/directory?weblog=<junk> fills the log with attacker-controlled text (CR/LF included). A cheap pre-check such as #if($requestedHandle && $requestedHandle.matches("[A-Za-z0-9_]+")) keeps garbage from reaching the manager.

#end
#if($profileWeblog)

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.

Handles may start with a digit (username.allowedChars defaults to A-Za-z0-9), and getWeblogsByLetter handled ?letter=2 before. Now 2 isn't a key in the A-Z map, so "Back to blog directory" from such a profile lands on the unfiltered list. Either accept the resolved handle's first character in _blogdirectory.vm (it's trusted) or omit the letter param when it isn't A-Z.

<a href="?letter=$utils.escapeHTML($utils.left($profileWeblog.handle,1))">Back to blog directory</a>

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: escapeHTML can't change anything here, $profileWeblog.handle already passed the [A-Za-z0-9_] check in getWeblogByHandle.

#includeTemplate($model.weblog "_blogprofile")
#else
#set($pageLength = $maxResults)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,14 @@ public void testGetUserNameLetterMap() throws Exception {
@Test
public void testGetWeblogLetterMap() throws Exception {
WeblogManager mgr = WebloggerFactory.getWeblogger().getWeblogManager();
Map<String, Long> map = mgr.getWeblogHandleLetterMap();
assertNotNull(map.get("A"));
assertNotNull(map.get("B"));
assertNotNull(map.get("C"));
Map<String, Long> map = mgr.getWeblogHandleLetterMap();
// The frontpage blog directory validates its letter parameter against
// these keys, so the contract is the exact A-Z set rather than a
// sample: a missing key would silently reject a legitimate letter.
assertEquals(26, map.size(), "expected the complete A-Z key set");
for (char c = 'A'; c <= 'Z'; c++) {
assertNotNull(map.get(String.valueOf(c)), "missing key " + c);
}
}

@AfterEach
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/*
* 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.Paths;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;

import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.VelocityEngine;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* Renders the bundled frontpage blog-directory template against the real
* Velocity engine and asserts how it treats the caller-supplied
* <code>letter</code> parameter.
*
* <p>The template is reached anonymously, so the parameter is untrusted. The
* contract is that only a value which normalizes to one of the directory's own
* A-Z keys is used, and that anything else falls back to the complete directory
* without the rejected value appearing in the response in any form — raw,
* HTML-encoded, or URL-encoded.
*/
public class FrontpageDirectoryRenderingTest {

private static final String THEME_DIR = "src/main/webapp/themes/frontpage";
private static final String TEMPLATE = "_blogdirectory.vm";

private static VelocityEngine engine;

@BeforeAll
public static void setUpEngine() {
Properties props = new Properties();
props.setProperty("resource.loaders", "file");
props.setProperty("resource.loader.file.class",
"org.apache.velocity.runtime.resource.loader.FileResourceLoader");
props.setProperty("resource.loader.file.path", THEME_DIR);

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.

app/pom.xml already copies src/main/webapp/themes/** onto the test classpath (that's how themes.dir in roller-custom.properties works), so a ClasspathResourceLoader rooted at themes/frontpage/ drops the working-directory dependence.

engine = new VelocityEngine();
engine.init(props);
}

/** Minimal stand-ins for the model objects the template reads. */
public static class StubModel {
private final String letter;
StubModel(String letter) { this.letter = letter; }
public String getRequestParameter(String name) {
return "letter".equals(name) ? letter : null;
}
}

public static class StubPager {
public List<Object> getItems() { return new ArrayList<>(); }
public String prevLink() { return null; }
public String nextLink() { return null; }
public String prevName() { return null; }
public String nextName() { return null; }
}

public static class StubSite {
public Map<String, Long> getWeblogHandleLetterMap() {
Map<String, Long> map = new LinkedHashMap<>();
for (char c = 'A'; c <= 'Z'; c++) {
map.put(String.valueOf(c), 1L);
}
return map;
}
public StubPager getWeblogsByLetterPager(String letter, int offset, int length) {
return new StubPager();
}
}

public static class StubUtils {

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: UtilitiesModel has a no-arg constructor and left / escapeHTML are stateless, so ctx.put("utils", new UtilitiesModel()) tests the real escapeHtml4 instead of a four-replace stand-in.

public String escapeHTML(String str) {
return str == null ? null : str.replace("&", "&amp;").replace("<", "&lt;")
.replace(">", "&gt;").replace("\"", "&quot;");
}
public String left(String str, int len) {
if (str == null) { return null; }
return str.length() <= len ? str : str.substring(0, len);
}
}

public static class StubUrl {
public String getAbsoluteSite() { return "http://example.test"; }
}

private String render(String letterParam) throws Exception {
VelocityContext ctx = new VelocityContext();
ctx.put("model", new StubModel(letterParam));
ctx.put("site", new StubSite());
ctx.put("utils", new StubUtils());
ctx.put("url", new StubUrl());
ctx.put("pageLength", 30);
StringWriter out = new StringWriter();
engine.mergeTemplate(TEMPLATE, "UTF-8", ctx, out);
return out.toString();
}

@Test
public void missingLetterRendersCompleteDirectory() throws Exception {
String html = render(null);
assertTrue(html.contains("All weblogs"),
"a missing letter must render the complete directory:\n" + html);
assertFalse(html.contains("Weblogs starting with"),
"a missing letter must not render a filtered heading");
}

@Test
public void validUppercaseLetterIsAccepted() throws Exception {
String html = render("A");
assertTrue(html.contains("Weblogs starting with A"),
"a valid key must be accepted:\n" + html);
}

@Test
public void lowercaseLetterNormalizesToTheSameGroup() throws Exception {
assertTrue(render("a").contains("Weblogs starting with A"),
"lowercase input must normalize to the uppercase key");
}

/**
* Every value that is not a single A-Z key must be discarded outright and
* must not be echoed, raw or encoded.
*/
@Test
public void invalidValuesFallBackAndAreNotEchoed() throws Exception {
String[] rejected = {
"AB", // multi-character
"1", // numeric
"!", // punctuation
"é", // non-ASCII
"<script>alert(1)</script>", // script payload
"\" onmouseover=\"alert(1)", // attribute-breaking payload
"A<b>", // valid prefix, invalid remainder
};
for (String value : rejected) {
String html = render(value);
assertTrue(html.contains("All weblogs"),
"rejected value [" + value + "] must fall back to the complete "
+ "directory:\n" + html);
// Assert against the heading directly. A bare contains(value) would
// match incidentally: single characters such as "1" occur naturally
// in the rendered letter counts.
assertFalse(html.contains("Weblogs starting with"),
"rejected value [" + value + "] produced a filtered heading:\n" + html);
assertFalse(html.contains("<script") || html.contains("&lt;script"),
"rejected value [" + value + "] reached the page, raw or encoded:\n" + html);
assertFalse(html.contains("onmouseover"),
"rejected value [" + value + "] leaked an event handler:\n" + html);
}
}

/**
* The sibling directory template resolves a weblog handle from the query
* string. It cannot be rendered standalone here because it pulls in other
* templates through #includeTemplate, so this is a structural check: the
* link must be built from the resolved weblog rather than the raw
* parameter, and escaped at output.
*/
@Test
public void directoryTemplateValidatesTheWeblogParameter() throws Exception {

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 asserts source substrings of directory.vm, so a whitespace change breaks it while a re-introduced raw parameter under another variable name passes. #includeTemplate is a plain velocimacro in WEB-INF/velocity/weblog.vm, so the test can stub it inline (#macro(includeTemplate $w $p)#end before #parse('directory.vm')) and assert the rendered output: no profile for an unknown handle, back-link built from the resolved handle for a known one.

String vm = new String(Files.readAllBytes(Paths.get(THEME_DIR, "directory.vm")),
StandardCharsets.UTF_8);
assertFalse(vm.contains("$utils.left($handle,1)"),
"the back-link must not be built from the raw weblog parameter:\n" + vm);
assertTrue(vm.contains("$site.getWeblog($requestedHandle)"),
"the requested handle must be resolved before use:\n" + vm);
assertTrue(vm.contains("$utils.escapeHTML($utils.left($profileWeblog.handle,1))"),
"the back-link must escape the resolved handle:\n" + vm);
}

/**
* Guards the test itself: if the template stopped rendering, or the theme
* moved, every assertion above would pass or fail for the wrong reason.
*/
@Test
public void templateActuallyRenders() throws Exception {
String html = render("A");
assertTrue(html.contains("blogdirectory"),
"expected the directory table to render:\n" + html);
assertTrue(html.contains("letterMap"),
"expected the A-Z letter map to render:\n" + html);
}
}
Loading