|
11 | 11 |
|
12 | 12 | import java.io.IOException; |
13 | 13 | import java.net.URL; |
| 14 | +import java.net.URLClassLoader; |
14 | 15 | import java.nio.file.Files; |
15 | 16 | import java.nio.file.Path; |
16 | 17 |
|
@@ -75,54 +76,31 @@ public void classpathPrefixAcceptsJarEntryUrls() throws IOException { |
75 | 76 | } |
76 | 77 |
|
77 | 78 | @Test |
78 | | - public void shouldBlockContainsBypassInClasspath() throws IOException { |
79 | | - Resource mockResource = mock(Resource.class); |
80 | | - when(mockResource.exists()).thenReturn(true); |
81 | | - when(mockResource.isFile()).thenReturn(false); |
82 | | - |
83 | | - // Simulate an attacker traversing out, but targeting a file inside another directory |
84 | | - // that happens to include the prefix string ("/templates/") in its path. |
85 | | - URL maliciousUrl = new URL("jar:file:/app.jar!/com/myapp/secret/templates/keys.hbs"); |
86 | | - when(mockResource.getURL()).thenReturn(maliciousUrl); |
87 | | - |
88 | | - // Spring resolves the traversal logically before Handlebars gets it |
89 | | - when(resourceLoader.getResource( |
90 | | - "classpath:/templates/../../com/myapp/secret/templates/keys.hbs")) |
91 | | - .thenReturn(mockResource); |
92 | | - |
| 79 | + public void shouldBlockContainsBypassInClasspath() { |
| 80 | + // The logical check happens before resource resolution, so we don't need mock behavior. |
| 81 | + // It catches the traversal on the logical string level immediately. |
93 | 82 | IllegalArgumentException exception = |
94 | 83 | assertThrows( |
95 | 84 | IllegalArgumentException.class, |
96 | 85 | () -> { |
97 | 86 | templateLoader.sourceAt("../../com/myapp/secret/templates/keys"); |
98 | 87 | }); |
99 | 88 |
|
100 | | - assertTrue(exception.getMessage().contains("escapes Spring base prefix")); |
| 89 | + assertTrue(exception.getMessage().contains("Logical path escapes classpath prefix")); |
101 | 90 | } |
102 | 91 |
|
103 | 92 | @Test |
104 | | - public void shouldEnforceLogicalBoundaryAgainstTraversal() throws IOException { |
105 | | - Resource mockResource = mock(Resource.class); |
106 | | - when(mockResource.exists()).thenReturn(true); |
107 | | - when(mockResource.isFile()).thenReturn(false); |
108 | | - |
109 | | - URL maliciousUrl = new URL("file:///application.properties.hbs"); |
110 | | - when(mockResource.getURL()).thenReturn(maliciousUrl); |
111 | | - |
112 | | - when(resourceLoader.getResource("classpath:/templates/../../application.properties.hbs")) |
113 | | - .thenReturn(mockResource); |
114 | | - |
115 | | - String maliciousPath = "../../application.properties"; |
116 | | - |
| 93 | + public void shouldEnforceLogicalBoundaryAgainstTraversal() { |
| 94 | + // The logical check happens before resource resolution, so we don't need mock behavior. |
| 95 | + // It catches the traversal on the logical string level immediately. |
117 | 96 | IllegalArgumentException exception = |
118 | 97 | assertThrows( |
119 | 98 | IllegalArgumentException.class, |
120 | 99 | () -> { |
121 | | - // sourceAt triggers resolve() and then getResource() |
122 | | - templateLoader.sourceAt(maliciousPath); |
| 100 | + templateLoader.sourceAt("../../application.properties"); |
123 | 101 | }); |
124 | 102 |
|
125 | | - assertTrue(exception.getMessage().contains("escapes Spring base")); |
| 103 | + assertTrue(exception.getMessage().contains("Logical path escapes classpath prefix")); |
126 | 104 | } |
127 | 105 |
|
128 | 106 | @Test |
@@ -223,11 +201,40 @@ public void shouldBlockPartialDirectoryMatch() throws IOException { |
223 | 201 |
|
224 | 202 | // Use "../" to back out of the Handlebars-enforced slash, traversing into the sibling |
225 | 203 | // directory. |
226 | | - // Handlebars resolves this to: .../templates/../templates-secret/secret.hbs |
227 | 204 | IllegalArgumentException exception = |
228 | 205 | assertThrows( |
229 | 206 | IllegalArgumentException.class, () -> loader.sourceAt("../templates-secret/secret")); |
230 | 207 |
|
231 | 208 | assertTrue(exception.getMessage().contains("escapes Spring base")); |
232 | 209 | } |
| 210 | + |
| 211 | + @Test |
| 212 | + public void shouldResolveTemplatesAcrossMultiModuleClasspath() throws IOException { |
| 213 | + Path root = Files.createTempDirectory("handlebars-multimodule"); |
| 214 | + |
| 215 | + // Simulate Module A (e.g., target/classes/) |
| 216 | + Path moduleA = Files.createDirectory(root.resolve("moduleA")); |
| 217 | + Path templatesA = Files.createDirectory(moduleA.resolve("templates")); |
| 218 | + Files.writeString(templatesA.resolve("template-a.hbs"), "Template A"); |
| 219 | + |
| 220 | + // Simulate Module B (e.g., target/test-classes/ or a sibling module) |
| 221 | + Path moduleB = Files.createDirectory(root.resolve("moduleB")); |
| 222 | + Path templatesB = Files.createDirectory(moduleB.resolve("templates")); |
| 223 | + Files.writeString(templatesB.resolve("template-b.hbs"), "Template B"); |
| 224 | + |
| 225 | + // Create a ClassLoader containing both roots |
| 226 | + URLClassLoader classLoader = |
| 227 | + new URLClassLoader( |
| 228 | + new URL[] {moduleA.toUri().toURL(), moduleB.toUri().toURL()}, |
| 229 | + getClass().getClassLoader()); |
| 230 | + |
| 231 | + // Use Spring's DefaultResourceLoader backed by our multi-root classloader |
| 232 | + SpringTemplateLoader loader = new SpringTemplateLoader(new DefaultResourceLoader(classLoader)); |
| 233 | + loader.setPrefix("classpath:/templates/"); |
| 234 | + loader.setSuffix(".hbs"); |
| 235 | + |
| 236 | + // The loader should find templates in both roots flawlessly without canonical file mismatches |
| 237 | + assertNotNull(loader.sourceAt("template-a")); |
| 238 | + assertNotNull(loader.sourceAt("template-b")); |
| 239 | + } |
233 | 240 | } |
0 commit comments