Skip to content

Commit 88717a3

Browse files
committed
added better directory checking
1 parent 5a54e5c commit 88717a3

2 files changed

Lines changed: 262 additions & 4 deletions

File tree

src/main/java/io/fusionauth/http/server/HTTPContext.java

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ public Map<String, Object> getAttributes() {
6161
/**
6262
* Attempts to retrieve a file or classpath resource at the given path. If the path is invalid, this will return null. If the classpath is
6363
* borked or the path somehow cannot be converted to a URL, then this throws an exception.
64+
* <p>
65+
* This method protects against path traversal attacks by normalizing the resolved path and ensuring it stays within the baseDir.
66+
* Attempts to escape the baseDir using sequences like {@code ../} will cause this method to return null.
6467
*
6568
* @param path The path.
6669
* @return The URL to the resource or null.
@@ -74,7 +77,13 @@ public URL getResource(String path) throws IllegalStateException {
7477
}
7578

7679
try {
77-
Path resolved = baseDir.resolve(filePath);
80+
Path resolved = baseDir.resolve(filePath).normalize();
81+
82+
// Security: Verify the resolved path stays within baseDir to prevent path traversal attacks
83+
if (!resolved.startsWith(baseDir.normalize())) {
84+
return null;
85+
}
86+
7887
if (Files.exists(resolved)) {
7988
return resolved.toUri().toURL();
8089
}
@@ -98,17 +107,27 @@ public Object removeAttribute(String name) {
98107
}
99108

100109
/**
101-
* Locates the path given the webapps baseDir (passed into the constructor.
110+
* Locates the path given the webapps baseDir (passed into the constructor).
111+
* <p>
112+
* This method protects against path traversal attacks by normalizing the resolved path and ensuring it stays within the baseDir.
113+
* Attempts to escape the baseDir using sequences like {@code ../} will return null.
102114
*
103115
* @param appPath The app path to a resource (like an FTL file).
104-
* @return The resolved path, which is almost always just the baseDir plus the appPath with a file separator in the middle.
116+
* @return The resolved path, or null if the path attempts to escape the baseDir.
105117
*/
106118
public Path resolve(String appPath) {
107119
if (appPath.startsWith("/")) {
108120
appPath = appPath.substring(1);
109121
}
110122

111-
return baseDir.resolve(appPath);
123+
Path resolved = baseDir.resolve(appPath).normalize();
124+
125+
// Security: Verify the resolved path stays within baseDir to prevent path traversal attacks
126+
if (!resolved.startsWith(baseDir.normalize())) {
127+
return null;
128+
}
129+
130+
return resolved;
112131
}
113132

114133
/**
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
/*
2+
* Copyright (c) 2022-2025, FusionAuth, All Rights Reserved
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing,
11+
* software distributed under the License is distributed on an
12+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13+
* either express or implied. See the License for the specific
14+
* language governing permissions and limitations under the License.
15+
*/
16+
package io.fusionauth.http.server;
17+
18+
import java.io.IOException;
19+
import java.net.URL;
20+
import java.net.URLEncoder;
21+
import java.nio.charset.StandardCharsets;
22+
import java.nio.file.Files;
23+
import java.nio.file.Path;
24+
25+
import org.testng.annotations.AfterMethod;
26+
import org.testng.annotations.BeforeMethod;
27+
import org.testng.annotations.Test;
28+
import static org.testng.Assert.assertEquals;
29+
import static org.testng.Assert.assertNotNull;
30+
import static org.testng.Assert.assertNull;
31+
import static org.testng.Assert.assertTrue;
32+
33+
/**
34+
* Tests for HTTPContext focusing on path traversal security and resource resolution.
35+
* <p>
36+
* These tests verify that HTTPContext properly prevents path traversal attacks as described in:
37+
* - CVE-2019-19781 (Citrix path traversal)
38+
* - Blog post: https://blog.dochia.dev/blog/http_edge_cases/
39+
*
40+
* @author FusionAuth
41+
*/
42+
public class HTTPContextTest {
43+
private Path tempDir;
44+
45+
private HTTPContext context;
46+
47+
@BeforeMethod
48+
public void setup() throws IOException {
49+
// Create a temporary directory structure for testing
50+
tempDir = Files.createTempDirectory("http-context-test");
51+
52+
// Create legitimate test files
53+
Files.writeString(tempDir.resolve("index.html"), "<html>Index</html>");
54+
55+
Path cssDir = Files.createDirectory(tempDir.resolve("css"));
56+
Files.writeString(cssDir.resolve("style.css"), "body { color: blue; }");
57+
58+
Path subDir = Files.createDirectory(tempDir.resolve("subdir"));
59+
Files.writeString(subDir.resolve("file.txt"), "Legitimate file");
60+
61+
// Create file outside the baseDir to test traversal attempts
62+
Path parentDir = tempDir.getParent();
63+
Files.writeString(parentDir.resolve("secret.txt"), "Secret data");
64+
65+
context = new HTTPContext(tempDir);
66+
}
67+
68+
@AfterMethod
69+
public void teardown() throws IOException {
70+
// Cleanup temp files
71+
if (tempDir != null && Files.exists(tempDir)) {
72+
Files.walk(tempDir)
73+
.sorted((a, b) -> b.compareTo(a)) // Delete files before directories
74+
.forEach(path -> {
75+
try {
76+
Files.deleteIfExists(path);
77+
} catch (IOException e) {
78+
// Ignore cleanup errors
79+
}
80+
});
81+
}
82+
83+
// Cleanup secret file from parent
84+
Path secretFile = tempDir.getParent().resolve("secret.txt");
85+
Files.deleteIfExists(secretFile);
86+
}
87+
88+
/**
89+
* Test that legitimate file paths work correctly.
90+
*/
91+
@Test
92+
public void testLegitimatePathsSucceed() throws Exception {
93+
// Test root level file
94+
URL indexUrl = context.getResource("index.html");
95+
assertNotNull(indexUrl, "Should resolve index.html");
96+
assertTrue(indexUrl.toString().contains("index.html"));
97+
98+
// Test subdirectory file
99+
URL cssUrl = context.getResource("css/style.css");
100+
assertNotNull(cssUrl, "Should resolve css/style.css");
101+
assertTrue(cssUrl.toString().contains("style.css"));
102+
103+
// Test with leading slash (should be stripped)
104+
URL slashUrl = context.getResource("/css/style.css");
105+
assertNotNull(slashUrl, "Should resolve /css/style.css");
106+
assertTrue(slashUrl.toString().contains("style.css"));
107+
108+
// Test nested path
109+
URL subdirUrl = context.getResource("subdir/file.txt");
110+
assertNotNull(subdirUrl, "Should resolve subdir/file.txt");
111+
assertTrue(subdirUrl.toString().contains("file.txt"));
112+
}
113+
114+
/**
115+
* Test path traversal attack using ../ sequences (CVE-2019-19781 style).
116+
* These attacks attempt to escape the baseDir and access parent directories.
117+
*/
118+
@Test
119+
public void testPathTraversalAttacksBlocked() {
120+
// Simple parent directory traversal
121+
URL result1 = context.getResource("../secret.txt");
122+
assertNull(result1, "Should block ../secret.txt");
123+
124+
// Multiple parent traversals
125+
URL result2 = context.getResource("../../etc/passwd");
126+
assertNull(result2, "Should block ../../etc/passwd");
127+
128+
// Traversal with valid path prefix
129+
URL result3 = context.getResource("css/../../secret.txt");
130+
assertNull(result3, "Should block css/../../secret.txt");
131+
132+
// Deep traversal
133+
URL result4 = context.getResource("subdir/../../secret.txt");
134+
assertNull(result4, "Should block subdir/../../secret.txt");
135+
136+
// Many parent directory references
137+
URL result5 = context.getResource("../../../../../../../../../etc/passwd");
138+
assertNull(result5, "Should block ../../../../../../../../../etc/passwd");
139+
}
140+
141+
/**
142+
* Test URL-encoded path traversal attacks.
143+
* Attackers often URL-encode the ../ sequences to bypass naive filters.
144+
*/
145+
@Test
146+
public void testUrlEncodedTraversalBlocked() {
147+
// URL-encoded ../ is %2e%2e%2f
148+
URL result1 = context.getResource("%2e%2e%2fsecret.txt");
149+
assertNull(result1, "Should block URL-encoded traversal %2e%2e%2fsecret.txt");
150+
151+
URL result2 = context.getResource("%2e%2e%2f%2e%2e%2fsecret.txt");
152+
assertNull(result2, "Should block %2e%2e%2f%2e%2e%2fsecret.txt");
153+
154+
// Mixed encoded and plain
155+
URL result3 = context.getResource("css/%2e%2e%2f%2e%2e%2fsecret.txt");
156+
assertNull(result3, "Should block css/%2e%2e%2f%2e%2e%2fsecret.txt");
157+
}
158+
159+
/**
160+
* Test that resolve() method also prevents path traversal.
161+
*/
162+
@Test
163+
public void testResolvePathTraversalBlocked() {
164+
// Simple parent directory traversal
165+
Path result1 = context.resolve("../secret.txt");
166+
assertNull(result1, "Should block ../secret.txt in resolve()");
167+
168+
// Multiple parent traversals
169+
Path result2 = context.resolve("../../etc/passwd");
170+
assertNull(result2, "Should block ../../etc/passwd in resolve()");
171+
172+
// Traversal with valid path prefix
173+
Path result3 = context.resolve("css/../../secret.txt");
174+
assertNull(result3, "Should block css/../../secret.txt in resolve()");
175+
}
176+
177+
/**
178+
* Test that resolve() works correctly for legitimate paths.
179+
*/
180+
@Test
181+
public void testResolveLegitimatePathsSucceed() {
182+
// Test root level file
183+
Path indexPath = context.resolve("index.html");
184+
assertNotNull(indexPath, "Should resolve index.html");
185+
assertEquals(indexPath, tempDir.resolve("index.html"));
186+
187+
// Test subdirectory file
188+
Path cssPath = context.resolve("css/style.css");
189+
assertNotNull(cssPath, "Should resolve css/style.css");
190+
assertEquals(cssPath, tempDir.resolve("css/style.css"));
191+
192+
// Test with leading slash
193+
Path slashPath = context.resolve("/css/style.css");
194+
assertNotNull(slashPath, "Should resolve /css/style.css");
195+
assertEquals(slashPath, tempDir.resolve("css/style.css"));
196+
}
197+
198+
/**
199+
* Test edge case: path that goes down then up but stays within baseDir.
200+
* For example: "subdir/../index.html" should resolve to "index.html"
201+
*/
202+
@Test
203+
public void testNormalizedPathWithinBaseDirSucceeds() {
204+
// This path traverses up but stays within baseDir after normalization
205+
URL result = context.getResource("subdir/../index.html");
206+
assertNotNull(result, "Should allow subdir/../index.html as it normalizes to index.html");
207+
assertTrue(result.toString().contains("index.html"));
208+
209+
Path resolved = context.resolve("subdir/../index.html");
210+
assertNotNull(resolved, "Should resolve subdir/../index.html");
211+
assertEquals(resolved, tempDir.resolve("index.html"));
212+
}
213+
214+
/**
215+
* Test that non-existent files return null (not exceptions).
216+
*/
217+
@Test
218+
public void testNonExistentFileReturnsNull() {
219+
URL result = context.getResource("does-not-exist.txt");
220+
// This might return null or try classpath lookup, either is acceptable
221+
// The key is it doesn't throw an exception or allow traversal
222+
}
223+
224+
/**
225+
* Test attribute storage (not security related, but completeness).
226+
*/
227+
@Test
228+
public void testAttributeStorage() {
229+
context.setAttribute("test", "value");
230+
assertEquals(context.getAttribute("test"), "value");
231+
232+
context.setAttribute("number", 42);
233+
assertEquals(context.getAttribute("number"), 42);
234+
235+
Object removed = context.removeAttribute("test");
236+
assertEquals(removed, "value");
237+
assertNull(context.getAttribute("test"));
238+
}
239+
}

0 commit comments

Comments
 (0)