VDB
Sign up
HIGH7.5

GHSA-g29j-rwfv-h99w

Handlebars.java: Arbitrary file read in `SpringTemplateLoader` via URL-fragment suffix bypass

Quick fix

GHSA-g29j-rwfv-h99w — com.github.jknack:handlebars-springmvc: upgrade to the fixed version with the command below.

# pom.xml: bump <version>4.5.3</version> for com.github.jknack:handlebars-springmvc

Details

### 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<? 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

```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 `HandlebarsViewResolver.configure`

```java // 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.

Are you affected?

Enter the version of the package you're using.

Affected packages

Maven/com.github.jknack:handlebars-springmvc
Introduced in: 0Fixed in: 4.5.3
Fix# pom.xml: bump <version>4.5.3</version> for com.github.jknack:handlebars-springmvc

References