Summary
com.github.jknack.handlebars.springmvc.SpringTemplateLoader resolves Spring MVC view names into URLs via Spring's ResourceLoader without applying the path-containment check that protects every other URL-based loader in the project (ClassPathTemplateLoader, FileTemplateLoader, ServletContextTemplateLoader - all hardened by commit d177cdee).
The only remaining defense for file: / classpath: view names is the unconditional .hbs suffix appended by AbstractTemplateLoader.resolve(...). This suffix is the load-bearing security boundary that prevents a request like view=file:/etc/passwd from reading /etc/passwd instead of /etc/passwd.hbs.
This boundary is bypassed by a single character: # (the URL fragment delimiter).
When the view name ends with #, the appended .hbs lands inside the URL fragment. Both Spring's FileUrlResource.exists() (via URI.getSchemeSpecificPart()) and the JDK's URL.openStream() (via URL.getFile()) silently discard the fragment, so the file actually opened is the bare path the attacker specified - for example /etc/passwd rather than /etc/passwd.hbs. The compiled "template" is then parsed and rendered into the HTTP response body.
Result: unauthenticated, network-reachable, arbitrary file read of any file readable by the JVM process on any Spring MVC application that uses a default-configured HandlebarsViewResolver and exposes a controller that returns a (fully or partly) user-influenced view name.
Vulnerable Code
SpringTemplateLoader.resolve - preserves file: / classpath: and applies suffix to the path portion
// handlebars-springmvc/.../SpringTemplateLoader.java:66-77
@Override
public String resolve(final String location) {
String protocol = null;
if (location.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) {
protocol = ResourceUtils.CLASSPATH_URL_PREFIX;
} else if (location.startsWith(ResourceUtils.FILE_URL_PREFIX)) {
protocol = ResourceUtils.FILE_URL_PREFIX; // matches "file:"
}
if (protocol == null) {
return super.resolve(location);
}
return protocol + super.resolve(location.substring(protocol.length()));
}
SpringTemplateLoader.getResource - no containment check
// handlebars-springmvc/.../SpringTemplateLoader.java:57-63
@Override
protected URL getResource(final String location) throws IOException {
Resource resource = loader.getResource(location); // trust Spring blindly
if (!resource.exists()) {
return null;
}
return resource.getURL();
}
Contrast with the hardened sibling ClassPathTemplateLoader.getResource, which delegates to URLTemplateLoader.classpathResource(...) - the containment helper added by commit d177cdee:
// handlebars/.../io/URLTemplateLoader.java:75-93 (the d177cdee hardening)
protected final String classpathResource(String location) {
String resolvedPath =
Paths.get(location).normalize().toString().replace(java.io.File.separatorChar, '/');
if (location.startsWith("/") && !resolvedPath.startsWith("/")) {
resolvedPath = "/" + resolvedPath;
}
String prefix = getPrefix();
if (!prefix.equals("/") && !resolvedPath.startsWith(prefix)) {
throw new IllegalArgumentException(
"Path traversal attempt detected. Resolved path escapes base prefix: " + location);
}
return resolvedPath;
}
SpringTemplateLoader.getResource never calls this helper.
HandlebarsViewResolver - strips the outer prefix/suffix and forwards to compile, no validation
// handlebars-springmvc/.../HandlebarsViewResolver.java:112-117
public HandlebarsViewResolver(final Class<? extends HandlebarsView> viewClass) {
setViewClass(viewClass);
setContentType(DEFAULT_CONTENT_TYPE);
setPrefix(TemplateLoader.DEFAULT_PREFIX); // "/"
setSuffix(TemplateLoader.DEFAULT_SUFFIX); // ".hbs"
}
// handlebars-springmvc/.../HandlebarsViewResolver.java:163-178
protected AbstractUrlBasedView configure(final HandlebarsView view) throws IOException {
String url = view.getUrl();
url = url.substring(getPrefix().length(), url.length() - getSuffix().length());
try {
view.setTemplate(handlebars.compile(url)); // ← attacker-controlled url
view.setValueResolver(valueResolvers.toArray(new ValueResolver[0]));
} catch (IOException ex) {
if (failOnMissingFile) throw ex;
logger.debug("File not found: " + url);
}
return view;
}
AbstractTemplateLoader.resolve - the load-bearing .hbs gate
// handlebars/.../io/AbstractTemplateLoader.java:47-50
@Override
public String resolve(final String uri) {
return prefix + normalize(uri) + suffix; // "/" + path + ".hbs"
}
The suffix string is concatenated as a string. Whether that string lands in the path component, query component, or fragment component of the resulting URL is decided by Spring's URL parsing - not by Handlebars.
Vulnerable Code
SpringTemplateLoader.resolve - preserves file: / classpath: and applies suffix to the path portion
// handlebars-springmvc/.../SpringTemplateLoader.java:66-77
@Override
public String resolve(final String location) {
String protocol = null;
if (location.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) {
protocol = ResourceUtils.CLASSPATH_URL_PREFIX;
} else if (location.startsWith(ResourceUtils.FILE_URL_PREFIX)) {
protocol = ResourceUtils.FILE_URL_PREFIX; // matches "file:"
}
if (protocol == null) {
return super.resolve(location);
}
return protocol + super.resolve(location.substring(protocol.length()));
}
SpringTemplateLoader.getResource - no containment check
// handlebars-springmvc/.../SpringTemplateLoader.java:57-63
@Override
protected URL getResource(final String location) throws IOException {
Resource resource = loader.getResource(location); // trust Spring blindly
if (!resource.exists()) {
return null;
}
return resource.getURL();
}
Contrast with the hardened sibling ClassPathTemplateLoader.getResource, which delegates to URLTemplateLoader.classpathResource(...) - the containment helper added by commit d177cdee:
// handlebars/.../io/URLTemplateLoader.java:75-93 (the d177cdee hardening)
protected final String classpathResource(String location) {
String resolvedPath =
Paths.get(location).normalize().toString().replace(java.io.File.separatorChar, '/');
if (location.startsWith("/") && !resolvedPath.startsWith("/")) {
resolvedPath = "/" + resolvedPath;
}
String prefix = getPrefix();
if (!prefix.equals("/") && !resolvedPath.startsWith(prefix)) {
throw new IllegalArgumentException(
"Path traversal attempt detected. Resolved path escapes base prefix: " + location);
}
return resolvedPath;
}
SpringTemplateLoader.getResource never calls this helper.
HandlebarsViewResolver - strips the outer prefix/suffix and forwards to compile, no validation
// handlebars-springmvc/.../HandlebarsViewResolver.java:112-117
public HandlebarsViewResolver(final Class<? extends HandlebarsView> viewClass) {
setViewClass(viewClass);
setContentType(DEFAULT_CONTENT_TYPE);
setPrefix(TemplateLoader.DEFAULT_PREFIX); // "/"
setSuffix(TemplateLoader.DEFAULT_SUFFIX); // ".hbs"
}
// handlebars-springmvc/.../HandlebarsViewResolver.java:163-178
protected AbstractUrlBasedView configure(final HandlebarsView view) throws IOException {
String url = view.getUrl();
url = url.substring(getPrefix().length(), url.length() - getSuffix().length());
try {
view.setTemplate(handlebars.compile(url)); // ← attacker-controlled url
view.setValueResolver(valueResolvers.toArray(new ValueResolver[0]));
} catch (IOException ex) {
if (failOnMissingFile) throw ex;
logger.debug("File not found: " + url);
}
return view;
}
AbstractTemplateLoader.resolve - the load-bearing .hbs gate
// handlebars/.../io/AbstractTemplateLoader.java:47-50
@Override
public String resolve(final String uri) {
return prefix + normalize(uri) + suffix; // "/" + path + ".hbs"
}
The suffix string is concatenated as a string. Whether that string lands in the path component, query component, or fragment component of the resulting URL is decided by Spring's URL parsing - not by Handlebars.
Impact
Direct primitive
Unauthenticated arbitrary file read of any file readable by the JVM process UID.
Real-world attack chains (downstream impact)
- Read
application.yml -> extract jwt.secret / spring.datasource.password -> forge admin JWT or directly connect to the database. Common Spring Boot deployment pattern; one request to game-over.
- Read AWS / GCP credentials -> assume role -> exfiltrate buckets, modify infrastructure.
- Read K8s service-account token -> API-server access scoped to the pod's role -> namespace lateral movement, secret exfiltration.
- Read
/proc/self/environ -> harvest CI/CD-injected secrets that never appear on disk.
- Read private keys (
id_rsa, TLS keys) -> impersonate host / decrypt MITM'd traffic / sign commits.
- Read the application's source-code-on-disk to discover further server-side endpoints, hardcoded credentials, or chains.
Indirect
- Confirmed reachable from any controller that returns user-influenced view names - a documented Spring anti-pattern that nevertheless appears in production (CMS preview endpoints, theme switchers, multi-tenant view routing,
@RequestMapping("/{view}") patterns, DefaultRequestToViewNameTranslator-driven URL->view mappings).
- No authentication, no privilege, no clicks - a single Internet HTTP GET.
Remediation
Any of the following independently closes the bypass. We recommend implementing #1 and #2 for defense in depth.
Apply the containment helper to SpringTemplateLoader.getResource (parity with d177cde)
// handlebars-springmvc/.../SpringTemplateLoader.java
@Override
protected URL getResource(final String location) throws IOException {
// For classpath: locations, delegate to the hardened helper as ClassPathTemplateLoader does.
// For file: locations, perform an explicit canonical-path containment check.
Resource resource = loader.getResource(location);
if (!resource.exists()) {
return null;
}
URL url = resource.getURL();
validateNoUnsafeUrlComponents(url); // see 9.3
return url;
}
Validate the resolved URL components
private static void validateNoUnsafeUrlComponents(URL url) {
if (url.getRef() != null) {
throw new IllegalArgumentException(
"Template URL must not contain a fragment: " + url);
}
if (url.getQuery() != null) {
throw new IllegalArgumentException(
"Template URL must not contain a query: " + url);
}
}
This is the structural fix - it ensures the textual .hbs check matches the resolved-file behavior regardless of input shape.
Remove the protocol short-circuit entirely
If the supported deployment model is "templates live in one well-known prefix", SpringTemplateLoader.resolve should not preserve file: / classpath: prefixes from user input at all. Either remove that branch, or require an explicit allow-list in the constructor:
public SpringTemplateLoader(ResourceLoader loader, boolean allowProtocolPrefixes) { ... }
with the default being false.
Validate the stripped view name in HandlebarsViewResolver.configure
// handlebars-springmvc/.../HandlebarsViewResolver.java:163-178
protected AbstractUrlBasedView configure(final HandlebarsView view) throws IOException {
String url = view.getUrl();
url = url.substring(getPrefix().length(), url.length() - getSuffix().length());
if (url.contains(":") || url.contains("#") || url.contains("..")) {
throw new IllegalArgumentException("Unsafe view name: " + url);
}
// ...
}
This is a defense-in-depth check that rejects view names containing protocols, fragments, or traversal sequences. It does not by itself remove the SpringTemplateLoader weakness (developers calling handlebars.compile(...) directly still bypass it), but it eliminates the most common reach pattern.
Summary
com.github.jknack.handlebars.springmvc.SpringTemplateLoaderresolves Spring MVC view names into URLs via Spring'sResourceLoaderwithout applying the path-containment check that protects every other URL-based loader in the project (ClassPathTemplateLoader,FileTemplateLoader,ServletContextTemplateLoader- all hardened by commitd177cdee).The only remaining defense for
file:/classpath:view names is the unconditional.hbssuffix appended byAbstractTemplateLoader.resolve(...). This suffix is the load-bearing security boundary that prevents a request likeview=file:/etc/passwdfrom reading/etc/passwdinstead of/etc/passwd.hbs.This boundary is bypassed by a single character:
#(the URL fragment delimiter).When the view name ends with
#, the appended.hbslands inside the URL fragment. Both Spring'sFileUrlResource.exists()(viaURI.getSchemeSpecificPart()) and the JDK'sURL.openStream()(viaURL.getFile()) silently discard the fragment, so the file actually opened is the bare path the attacker specified - for example/etc/passwdrather than/etc/passwd.hbs. The compiled "template" is then parsed and rendered into the HTTP response body.Result: unauthenticated, network-reachable, arbitrary file read of any file readable by the JVM process on any Spring MVC application that uses a default-configured
HandlebarsViewResolverand exposes a controller that returns a (fully or partly) user-influenced view name.Vulnerable Code
SpringTemplateLoader.resolve- preservesfile:/classpath:and applies suffix to the path portionSpringTemplateLoader.getResource- no containment checkContrast with the hardened sibling
ClassPathTemplateLoader.getResource, which delegates toURLTemplateLoader.classpathResource(...)- the containment helper added by commitd177cdee:SpringTemplateLoader.getResourcenever calls this helper.HandlebarsViewResolver- strips the outer prefix/suffix and forwards to compile, no validationAbstractTemplateLoader.resolve- the load-bearing.hbsgateThe suffix string is concatenated as a string. Whether that string lands in the path component, query component, or fragment component of the resulting URL is decided by Spring's URL parsing - not by Handlebars.
Vulnerable Code
SpringTemplateLoader.resolve- preservesfile:/classpath:and applies suffix to the path portionSpringTemplateLoader.getResource- no containment checkContrast with the hardened sibling
ClassPathTemplateLoader.getResource, which delegates toURLTemplateLoader.classpathResource(...)- the containment helper added by commitd177cdee:SpringTemplateLoader.getResourcenever calls this helper.HandlebarsViewResolver- strips the outer prefix/suffix and forwards to compile, no validationAbstractTemplateLoader.resolve- the load-bearing.hbsgateThe suffix string is concatenated as a string. Whether that string lands in the path component, query component, or fragment component of the resulting URL is decided by Spring's URL parsing - not by Handlebars.
Impact
Direct primitive
Unauthenticated arbitrary file read of any file readable by the JVM process UID.
Real-world attack chains (downstream impact)
application.yml-> extractjwt.secret/spring.datasource.password-> forge admin JWT or directly connect to the database. Common Spring Boot deployment pattern; one request to game-over./proc/self/environ-> harvest CI/CD-injected secrets that never appear on disk.id_rsa, TLS keys) -> impersonate host / decrypt MITM'd traffic / sign commits.Indirect
@RequestMapping("/{view}")patterns,DefaultRequestToViewNameTranslator-driven URL->view mappings).Remediation
Any of the following independently closes the bypass. We recommend implementing #1 and #2 for defense in depth.
Apply the containment helper to
SpringTemplateLoader.getResource(parity with d177cde)Validate the resolved URL components
This is the structural fix - it ensures the textual
.hbscheck matches the resolved-file behavior regardless of input shape.Remove the protocol short-circuit entirely
If the supported deployment model is "templates live in one well-known prefix",
SpringTemplateLoader.resolveshould not preservefile:/classpath:prefixes from user input at all. Either remove that branch, or require an explicit allow-list in the constructor:with the default being
false.Validate the stripped view name in
HandlebarsViewResolver.configureThis is a defense-in-depth check that rejects view names containing protocols, fragments, or traversal sequences. It does not by itself remove the SpringTemplateLoader weakness (developers calling
handlebars.compile(...)directly still bypass it), but it eliminates the most common reach pattern.