Skip to content

Commit a8d38a3

Browse files
authored
Merge commit from fork
fix(loader): confine template resource loaders to their root (GHSA-fxwq-p235-p87r)
2 parents f24e265 + 5fbe59c commit a8d38a3

5 files changed

Lines changed: 160 additions & 13 deletions

File tree

‎src/main/java/com/hubspot/jinjava/loader/ClasspathResourceLocator.java‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import com.hubspot.jinjava.interpret.JinjavaInterpreter;
55
import java.io.IOException;
66
import java.nio.charset.Charset;
7+
import java.nio.file.Path;
8+
import java.nio.file.Paths;
79

810
public class ClasspathResourceLocator implements ResourceLocator {
911

@@ -13,10 +15,30 @@ public String getString(
1315
Charset encoding,
1416
JinjavaInterpreter interpreter
1517
) throws IOException {
18+
if (escapesClasspathRoot(fullName)) {
19+
throw new ResourceNotFoundException("Path escapes classpath root: " + fullName);
20+
}
21+
1622
try {
1723
return Resources.toString(Resources.getResource(fullName), encoding);
1824
} catch (IllegalArgumentException e) {
1925
throw new ResourceNotFoundException("Couldn't find resource: " + fullName);
2026
}
2127
}
28+
29+
private static boolean escapesClasspathRoot(String fullName) {
30+
Path path = Paths.get(fullName);
31+
32+
if (path.isAbsolute()) {
33+
return true;
34+
}
35+
36+
for (Path segment : path) {
37+
if (segment.toString().equals("..")) {
38+
return true;
39+
}
40+
}
41+
42+
return false;
43+
}
2244
}

‎src/main/java/com/hubspot/jinjava/loader/FileLocator.java‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import java.io.FileNotFoundException;
2222
import java.io.IOException;
2323
import java.nio.charset.Charset;
24+
import java.nio.file.Path;
2425

2526
public class FileLocator implements ResourceLocator {
2627

@@ -42,14 +43,15 @@ public FileLocator(File baseDir) throws FileNotFoundException {
4243
this.baseDir = baseDir;
4344
}
4445

45-
private File resolveFileName(String name) {
46-
File f = new File(name);
46+
private File resolveFileName(String name) throws IOException {
47+
Path root = baseDir.getCanonicalFile().toPath();
48+
File resolved = root.resolve(name).normalize().toFile().getCanonicalFile();
4749

48-
if (f.isAbsolute()) {
49-
return f;
50+
if (!resolved.toPath().startsWith(root)) {
51+
throw new ResourceNotFoundException("Path escapes template root: " + name);
5052
}
5153

52-
return new File(baseDir, name);
54+
return resolved;
5355
}
5456

5557
@Override

‎src/test/java/com/hubspot/jinjava/loader/ClasspathResourceLocatorTest.java‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,16 @@ public void testLoadFromClasspath() throws Exception {
2121
public void itThrowsNotFoundWhenNotFound() throws Exception {
2222
new ClasspathResourceLocator().getString("foo", StandardCharsets.UTF_8, interpreter);
2323
}
24+
25+
@Test(expected = ResourceNotFoundException.class)
26+
public void itBlocksDotDotTraversal() throws Exception {
27+
new ClasspathResourceLocator()
28+
.getString("loader/cp/foo/../../dummy.html", StandardCharsets.UTF_8, interpreter);
29+
}
30+
31+
@Test(expected = ResourceNotFoundException.class)
32+
public void itBlocksAbsolutePaths() throws Exception {
33+
new ClasspathResourceLocator()
34+
.getString("/loader/cp/foo/bar.jinja", StandardCharsets.UTF_8, interpreter);
35+
}
2436
}

‎src/test/java/com/hubspot/jinjava/loader/FileLocatorTest.java‎

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,30 @@
22

33
import static org.assertj.core.api.Assertions.assertThat;
44

5-
import com.google.common.io.Files;
65
import com.hubspot.jinjava.BaseInterpretingTest;
7-
import com.hubspot.jinjava.Jinjava;
8-
import com.hubspot.jinjava.interpret.JinjavaInterpreter;
96
import java.io.File;
107
import java.io.FileNotFoundException;
118
import java.nio.charset.StandardCharsets;
9+
import java.nio.file.Files;
1210
import org.junit.Before;
1311
import org.junit.Test;
1412

1513
public class FileLocatorTest extends BaseInterpretingTest {
1614

1715
FileLocator locatorWorkingDir;
1816
FileLocator locatorTmpDir;
17+
FileLocator locatorConfined;
1918

2019
File first;
2120
File second;
21+
File inside;
22+
File secretOutside;
2223

2324
@Before
2425
public void setUp() throws Exception {
2526
locatorWorkingDir = new FileLocator();
2627

27-
File tmpDir = java.nio.file.Files
28-
.createTempDirectory(getClass().getSimpleName())
29-
.toFile();
28+
File tmpDir = Files.createTempDirectory(getClass().getSimpleName()).toFile();
3029
locatorTmpDir = new FileLocator(tmpDir);
3130

3231
first = new File(tmpDir, "foo/first.jinja");
@@ -35,8 +34,21 @@ public void setUp() throws Exception {
3534
first.getParentFile().mkdirs();
3635
second.getParentFile().mkdirs();
3736

38-
Files.write("first", first, StandardCharsets.UTF_8);
39-
Files.write("second", second, StandardCharsets.UTF_8);
37+
Files.writeString(first.toPath(), "first");
38+
Files.writeString(second.toPath(), "second");
39+
40+
File parent = Files
41+
.createTempDirectory(getClass().getSimpleName() + "-confined")
42+
.toFile();
43+
File confinedDir = new File(parent, "base");
44+
confinedDir.mkdirs();
45+
locatorConfined = new FileLocator(confinedDir);
46+
47+
inside = new File(confinedDir, "inside.jinja");
48+
Files.writeString(inside.toPath(), "inside");
49+
50+
secretOutside = new File(parent, "secret.txt");
51+
Files.writeString(secretOutside.toPath(), "TOP_SECRET_FROM_OUTSIDE_DIR");
4052
}
4153

4254
@Test
@@ -102,4 +114,38 @@ public void testNotFoundRel() throws Exception {
102114
public void testNotFoundAbs() throws Exception {
103115
locatorWorkingDir.getString("/blargh", StandardCharsets.UTF_8, interpreter);
104116
}
117+
118+
@Test
119+
public void itAllowsInRootRelativePath() throws Exception {
120+
assertThat(
121+
locatorConfined.getString("inside.jinja", StandardCharsets.UTF_8, interpreter)
122+
)
123+
.isEqualTo("inside");
124+
}
125+
126+
@Test
127+
public void itAllowsInRootAbsolutePath() throws Exception {
128+
assertThat(
129+
locatorConfined.getString(
130+
inside.getAbsolutePath(),
131+
StandardCharsets.UTF_8,
132+
interpreter
133+
)
134+
)
135+
.isEqualTo("inside");
136+
}
137+
138+
@Test(expected = ResourceNotFoundException.class)
139+
public void itBlocksDotDotTraversalOutsideRoot() throws Exception {
140+
locatorConfined.getString("../secret.txt", StandardCharsets.UTF_8, interpreter);
141+
}
142+
143+
@Test(expected = ResourceNotFoundException.class)
144+
public void itBlocksAbsolutePathOutsideRoot() throws Exception {
145+
locatorConfined.getString(
146+
secretOutside.getAbsolutePath(),
147+
StandardCharsets.UTF_8,
148+
interpreter
149+
);
150+
}
105151
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.hubspot.jinjava.loader;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.hubspot.jinjava.Jinjava;
6+
import com.hubspot.jinjava.interpret.RenderResult;
7+
import java.io.File;
8+
import java.nio.file.Files;
9+
import java.util.HashMap;
10+
import org.junit.Before;
11+
import org.junit.Test;
12+
13+
public class LoaderPathTraversalTest {
14+
15+
private static final String SECRET = "TOP_SECRET_FROM_OUTSIDE_DIR";
16+
17+
private Jinjava jinjava;
18+
private File secretOutside;
19+
20+
@Before
21+
public void setUp() throws Exception {
22+
File parent = Files.createTempDirectory(getClass().getSimpleName()).toFile();
23+
File baseDir = new File(parent, "base");
24+
baseDir.mkdirs();
25+
26+
secretOutside = new File(parent, "secret.txt");
27+
Files.writeString(secretOutside.toPath(), SECRET);
28+
29+
jinjava = new Jinjava();
30+
jinjava.setResourceLocator(new FileLocator(baseDir));
31+
}
32+
33+
private String render(String template) {
34+
RenderResult result = jinjava.renderForResult(template, new HashMap<>());
35+
return result.getOutput();
36+
}
37+
38+
@Test
39+
public void itDoesNotDiscloseFileViaIncludeTraversal() {
40+
assertThat(render("{% include \"../secret.txt\" %}")).doesNotContain(SECRET);
41+
}
42+
43+
@Test
44+
public void itDoesNotDiscloseFileViaExtendsTraversal() {
45+
assertThat(render("{% extends \"../secret.txt\" %}")).doesNotContain(SECRET);
46+
}
47+
48+
@Test
49+
public void itDoesNotDiscloseFileViaImportTraversal() {
50+
assertThat(render("{% import \"../secret.txt\" as leaked %}{{ leaked }}"))
51+
.doesNotContain(SECRET);
52+
}
53+
54+
@Test
55+
public void itDoesNotDiscloseFileViaFromTraversal() {
56+
assertThat(render("{% from \"../secret.txt\" import leaked %}{{ leaked }}"))
57+
.doesNotContain(SECRET);
58+
}
59+
60+
@Test
61+
public void itDoesNotDiscloseFileViaAbsolutePathOutsideRoot() {
62+
assertThat(render("{% include \"" + secretOutside.getAbsolutePath() + "\" %}"))
63+
.doesNotContain(SECRET);
64+
}
65+
}

0 commit comments

Comments
 (0)