GitHub Advisoryadvisory · Maven
GHSA-g29j-rwfv-h99w Handlebars.java: Arbitrary file read in `SpringTemplateLoader` via URL-fragment suffix bypass
HIGH
Official page ↗
- Aliases
- CVE-2026-63490
- Ecosystem
- Maven
- Packages
- com.github.jknack:handlebars-springmvc
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
- Weaknesses
- CWE-22, CWE-23, CWE-552
- Reviewed
- 2026-09-02
Affected packages
• Maven com.github.jknack:handlebars-springmvc: < 4.5.3 → fixed in 4.5.3
Description
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
```java
// 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
```java
// 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`:
```java
// 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
```java
// handlebars-springmvc/.../HandlebarsViewResolver.java:112-117
public HandlebarsViewResolver(final Class 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
```java
// 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)
1. 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.
2. Read AWS / GCP credentials -> assume role -> exfiltrate buckets, modify infrastructure.
3. Read K8s service-account token -> API-server access scoped to the pod's role -> namespace lateral movement, secret exfiltration.
4. Read `/proc/self/environ` -> harvest CI/CD-injected secrets that never appear on disk.
5. Read private keys (`id_rsa`, TLS keys) -> impersonate host / decrypt MITM'd traffic / sign commits.
6. 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 d177cdee)
```java
// 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
```java
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:
```java
public SpringTemplateLoader(ResourceLoader loader, boolean allowProtocolPrefixes) { ... }
```
with the default being `false`.
Validate the stripped view name in `HandlebarsViewR…
Related
Alias · CVE (1)
- CVE-2026-63490 Handlebars.java provides logic-less and semantic Mustache templates with Java. vulnerability
References
- https://github.com/jknack/handlebars.java/security/advisories/GHSA-g29j-rwfv-h99w web
- https://nvd.nist.gov/vuln/detail/CVE-2026-63490 advisory
- https://github.com/jknack/handlebars.java/commit/61f43423a337b87db5fec1fe59f0725aaaa38df5 web
- https://github.com/jknack/handlebars.java package
- https://github.com/jknack/handlebars.java/releases/tag/v4.5.3 web